mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-12 21:13:18 +01:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdcc9e3d8 | ||
|
|
daee747338 | ||
|
|
535a163f00 | ||
|
|
bd61b91722 | ||
|
|
a0c1b072b6 | ||
|
|
fe806cc4eb | ||
|
|
aacad78cdb | ||
|
|
b89f2dd862 | ||
|
|
36d01477cc | ||
|
|
e4090d00c9 | ||
|
|
ff55bc8209 | ||
|
|
b375c9efdc | ||
|
|
67b4fde5c3 | ||
|
|
63c6581d14 | ||
|
|
3ddd01981d | ||
|
|
bbd845905a | ||
|
|
3f53c37187 | ||
|
|
159c0d1104 | ||
|
|
82db08b76f | ||
|
|
83e84228f5 | ||
|
|
05edc715ef | ||
|
|
c503df5eec | ||
|
|
1f242473fe | ||
|
|
f7db5f8ae0 | ||
|
|
3b88d7cf94 | ||
|
|
cac82f2eba | ||
|
|
5811b469cf | ||
|
|
af1175f32e | ||
|
|
7a5ce5761f | ||
|
|
34104355cb | ||
|
|
143c1255d8 | ||
|
|
a9d0e5ac81 | ||
|
|
c8b3ee51ed | ||
|
|
c964067139 | ||
|
|
539f590c4c | ||
|
|
71dddd4a1b | ||
|
|
792f5dd7b5 | ||
|
|
a3af49d92a | ||
|
|
4289b43a81 | ||
|
|
7f55623acf | ||
|
|
52060b65be | ||
|
|
fd826749e4 | ||
|
|
627e15c3dd | ||
|
|
90f6890180 | ||
|
|
61eb397d2b | ||
|
|
3a5e5364c7 | ||
|
|
25779d04a6 | ||
|
|
242900e87f | ||
|
|
05f07d1788 | ||
|
|
f961f4ccac | ||
|
|
145377b05f | ||
|
|
bc88887195 | ||
|
|
5362b1c21c | ||
|
|
0cfd3265ba | ||
|
|
1099128513 | ||
|
|
ad50c81a6b | ||
|
|
0817f113c6 | ||
|
|
4a9a07a9ef | ||
|
|
61f50cfe60 | ||
|
|
92888778c2 | ||
|
|
987f9b9dba | ||
|
|
97d95f37cc | ||
|
|
836cd04564 | ||
|
|
c26f54161d | ||
|
|
b540009ce6 | ||
|
|
e58e209950 | ||
|
|
4597a23104 | ||
|
|
3aacf4bcd2 | ||
|
|
6e6b663fac | ||
|
|
6dad7eafcf | ||
|
|
5a38143987 | ||
|
|
4d6d31d624 | ||
|
|
938c82be3f | ||
|
|
dc2e249566 | ||
|
|
bb8fdcabcb | ||
|
|
6cf4dbc78c | ||
|
|
8cd0ac5451 | ||
|
|
33745f0b0c | ||
|
|
29ffed219f | ||
|
|
9629d0f715 | ||
|
|
01651984d7 | ||
|
|
c7c15250ca | ||
|
|
36b96eafcc | ||
|
|
94f930ee22 | ||
|
|
3f740d2904 | ||
|
|
f8529adfcf | ||
|
|
77ccbdd322 | ||
|
|
f2846efd2c | ||
|
|
131f9c4bc9 | ||
|
|
d7c06fff50 | ||
|
|
09a22d9dc4 | ||
|
|
0fbab04253 | ||
|
|
c963e99dca | ||
|
|
7a555d127f | ||
|
|
866408f673 | ||
|
|
a7e5ab1a6a | ||
|
|
14d16d61e6 | ||
|
|
b988e4a813 | ||
|
|
ae33c8db1b | ||
|
|
9f1c5ac6bb | ||
|
|
448e7d0739 | ||
|
|
8971ff9057 | ||
|
|
2d6b16b2ce | ||
|
|
44ab1643fa | ||
|
|
a64bffd83a | ||
|
|
534c5c3c64 | ||
|
|
99d3f9918f | ||
|
|
aaebf029db | ||
|
|
e2c2ace0e3 | ||
|
|
c76002663f | ||
|
|
c5317370c8 | ||
|
|
4b09f4a654 | ||
|
|
0c57113d8e | ||
|
|
d7dd77a5af | ||
|
|
5c5b88ebcc | ||
|
|
8df0248d4f | ||
|
|
58e48fdf14 | ||
|
|
407fc56218 | ||
|
|
398527d3f1 | ||
|
|
59745a695c | ||
|
|
2aaeda6ca8 | ||
|
|
5c78de2f46 | ||
|
|
93efc21452 | ||
|
|
50ad005e7c | ||
|
|
71c3bcdd29 | ||
|
|
e5bf04a407 | ||
|
|
fe8b2cb761 | ||
|
|
7c37f929a5 | ||
|
|
1b82d10b39 | ||
|
|
6b6e2490e7 | ||
|
|
ebee54cf92 | ||
|
|
b7acfb0dcc | ||
|
|
65dab45582 | ||
|
|
43086f9582 | ||
|
|
ed1aa74aff | ||
|
|
3ba128793a | ||
|
|
ffbbdc1576 | ||
|
|
6ff55cfff7 | ||
|
|
e9f1f781e1 | ||
|
|
6da36fe098 | ||
|
|
acb6510312 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,4 +28,4 @@ jni/libspeex/.deps/
|
||||
pkcs11.password
|
||||
dev.keystore
|
||||
maps.key
|
||||
local/
|
||||
local/
|
||||
@@ -46,15 +46,15 @@ ktlint {
|
||||
version = "0.47.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1262
|
||||
def canonicalVersionName = "6.20.6"
|
||||
def canonicalVersionCode = 1272
|
||||
def canonicalVersionName = "6.22.7"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 5,
|
||||
'armeabi-v7a' : 6,
|
||||
'arm64-v8a' : 7,
|
||||
'x86' : 8,
|
||||
'x86_64' : 9]
|
||||
def abiPostFix = ['universal' : 10,
|
||||
'armeabi-v7a' : 11,
|
||||
'arm64-v8a' : 12,
|
||||
'x86' : 13,
|
||||
'x86_64' : 14]
|
||||
|
||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
@@ -185,6 +185,7 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
|
||||
@@ -200,6 +201,7 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"dc9fd472a5a9c871a3c7f76f1af60aa9c1f314abf2e8d1e0c4ba25c8aaa2848c\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
|
||||
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
@@ -208,6 +210,7 @@ android {
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
@@ -383,6 +386,7 @@ android {
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
@@ -463,6 +467,7 @@ dependencies {
|
||||
implementation libs.androidx.legacy.preference
|
||||
implementation libs.androidx.gridlayout
|
||||
implementation libs.androidx.exifinterface
|
||||
implementation libs.androidx.compose.rxjava3
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.multidex
|
||||
implementation libs.androidx.navigation.fragment.ktx
|
||||
@@ -520,12 +525,6 @@ dependencies {
|
||||
exclude group: 'com.google.protobuf'
|
||||
}
|
||||
|
||||
implementation(libs.signal.argon2) {
|
||||
artifact {
|
||||
type = "aar"
|
||||
}
|
||||
}
|
||||
|
||||
implementation libs.signal.ringrtc
|
||||
|
||||
implementation libs.leolin.shortcutbadger
|
||||
@@ -563,6 +562,7 @@ dependencies {
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
implementation libs.kotlinx.collections.immutable
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
spinnerImplementation project(":spinner")
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -30,8 +29,8 @@ class CallTableTest {
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.setTimestamp(callId, conversationId, -1L)
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
SignalDatabase.calls.setTimestamp(callId, harness.others[0], -1L)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(-1L, call?.timestamp)
|
||||
|
||||
@@ -43,7 +42,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -52,10 +50,10 @@ class CallTableTest {
|
||||
now
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
SignalDatabase.calls.deleteGroupCall(call!!)
|
||||
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
|
||||
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
|
||||
@@ -66,7 +64,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -74,7 +71,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
@@ -87,7 +84,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -95,7 +91,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
assertEquals(harness.self.id, call?.ringerRecipient)
|
||||
@@ -105,7 +101,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -113,7 +108,7 @@ class CallTableTest {
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.JOINED, call?.event)
|
||||
assertNull(call?.ringerRecipient)
|
||||
@@ -123,7 +118,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -132,7 +126,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
|
||||
@@ -140,14 +134,13 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -156,7 +149,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
|
||||
@@ -164,14 +157,13 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
@@ -180,7 +172,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
|
||||
@@ -188,7 +180,7 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@@ -196,7 +188,6 @@ class CallTableTest {
|
||||
fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -206,7 +197,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
|
||||
@@ -214,7 +205,7 @@ class CallTableTest {
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@@ -222,7 +213,6 @@ class CallTableTest {
|
||||
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -232,7 +222,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
}
|
||||
@@ -242,7 +232,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -252,7 +241,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.getCallById(callId, conversationId).let {
|
||||
SignalDatabase.calls.getCallById(callId, harness.others[0]).let {
|
||||
assertNotNull(it)
|
||||
assertEquals(now, it?.timestamp)
|
||||
}
|
||||
@@ -266,7 +255,7 @@ class CallTableTest {
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
assertEquals(1L, call?.timestamp)
|
||||
@@ -275,7 +264,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
callId = callId,
|
||||
recipientId = harness.others[0],
|
||||
@@ -291,7 +279,7 @@ class CallTableTest {
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DELETE, call?.event)
|
||||
}
|
||||
@@ -301,7 +289,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -319,7 +306,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -330,7 +317,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -346,7 +332,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -357,7 +343,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -375,7 +360,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -386,7 +371,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -412,7 +396,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -423,7 +407,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -439,7 +422,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -449,7 +432,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -465,7 +447,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -475,7 +457,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -501,7 +482,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
@@ -511,7 +492,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -537,7 +517,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
@@ -547,7 +527,6 @@ class CallTableTest {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val now = System.currentTimeMillis()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
@@ -565,7 +544,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
@@ -574,7 +553,6 @@ class CallTableTest {
|
||||
fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
@@ -592,7 +570,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
@@ -601,7 +579,6 @@ class CallTableTest {
|
||||
fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
@@ -619,7 +596,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
@@ -628,7 +605,6 @@ class CallTableTest {
|
||||
fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
@@ -645,7 +621,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
}
|
||||
@@ -653,7 +629,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -662,7 +637,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -672,7 +647,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -681,7 +655,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -691,7 +665,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -700,7 +673,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.CANCELLED_BY_RINGER
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
@@ -710,7 +683,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -719,7 +691,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -728,7 +700,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -737,7 +708,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -746,7 +717,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -755,7 +725,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
@@ -764,7 +734,6 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
@@ -773,7 +742,7 @@ class CallTableTest {
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
|
||||
@@ -111,6 +111,7 @@ class DatabaseConsistencyTest {
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.joinToString(separator = " ")
|
||||
.replace(Regex.fromLiteral(" ,"), ",")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex.fromLiteral("( "), "(")
|
||||
.replace(Regex.fromLiteral(" )"), ")")
|
||||
|
||||
@@ -122,6 +122,37 @@ class GroupTableTest {
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIRemapRecipientsThatHaveAConflict_thenIExpectDeletion() {
|
||||
val v2Group = insertPushGroupWithSelfAndOthers(
|
||||
listOf(
|
||||
harness.others[0],
|
||||
harness.others[1]
|
||||
)
|
||||
)
|
||||
|
||||
insertThread(v2Group)
|
||||
|
||||
groupTable.remapRecipient(harness.others[0], harness.others[1])
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIRemapRecipients_thenIExpectRemap() {
|
||||
val v2Group = insertPushGroup()
|
||||
insertThread(v2Group)
|
||||
|
||||
val newId = harness.others[1]
|
||||
groupTable.remapRecipient(harness.others[0], newId)
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
|
||||
assertEquals(setOf(harness.self.id, newId), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
|
||||
val v2Group = insertPushGroup()
|
||||
@@ -280,6 +311,31 @@ class GroupTableTest {
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
}
|
||||
|
||||
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
|
||||
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
|
||||
val otherMembers: List<DecryptedMember> = others.map { id ->
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
}
|
||||
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(listOf(selfMember) + otherMembers)
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +180,45 @@ class SQLiteDatabaseTest {
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun1.set(true)
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun2.set(true)
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.endTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
emptyArray(),
|
||||
emptyList(),
|
||||
Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
Optional.empty(),
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mock {
|
||||
|
||||
@@ -5,9 +5,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.signal.libsignal.svr2.PinHash;
|
||||
import org.whispersystems.signalservice.api.kbs.KbsData;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -24,16 +25,16 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("ab7e8499d21f80a6600b3b9ee349ac6d72c07e3359fe885a934ba7aa844429f8"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("3f33ce58eb25b40436592a30eae2a8fabab1899095f4e2fba6e2d0dc43b4a2d9cac5a3931748522393951e0e54dec769"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,16 +43,16 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("88a787415a2ecd79da0d1016a82a27c5c695c9a19b88b0aa1d35683280aa9a67"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("301d9dd1e96f20ce51083f67d3298fd37b97525de8324d5e12ed2d407d3d927b"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("9d9b05402ea39c17ff1c9298c8a0e86784a352aa02a74943bf8bcf07ec0f4b574a5b786ad0182c8d308d9eb06538b8c9"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,18 +61,18 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("9571f3fde1e58588ba49bcf82be1b301ca3859a6f59076f79a8f47181ef952bf"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("ab645acdccc1652a48a34b2ac6926340ff35c03034013f68760f20013f028dd8"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("11c0ba1834db15e47c172f6c987c64bd4cfc69c6047dd67a022afeec0165a10943f204d5b8f37b3cb7bab21c6dfc39c8"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
assertEquals("577939bccb2b6638c39222d5a97998a867c5e154e30b82cc120f2dd07a3de987", kbsData.getMasterKey().deriveRegistrationLock());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -80,18 +81,17 @@ public final class PinHashing_hashPin_Test {
|
||||
byte[] backupId = Hex.fromStringCondensed("717dc111a98423a57196512606822fca646c653facd037c10728f14ba0be2ab3");
|
||||
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("0432d735b32f66d0e3a70d4f9cc821a8529521a4937d26b987715d8eff4e4c54"));
|
||||
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
|
||||
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
|
||||
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
|
||||
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
|
||||
|
||||
|
||||
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("d2fedabd0d4c17a371491c9722578843a26be3b4923e28d452ab2fc5491e794b"), kbsData.getKbsAccessKey());
|
||||
assertArrayEquals(Hex.fromStringCondensed("877ef871ef1fc668401c717ef21aa12e8523579fb1ff4474b76f28c2293537c80cc7569996c9e0229bea7f378e3a824e"), kbsData.getCipherText());
|
||||
assertEquals(masterKey, kbsData.getMasterKey());
|
||||
|
||||
assertEquals("23a75cb1df1a87df45cc2ed167c2bdc85ab1220b847c88761b0005cac907fce5", kbsData.getMasterKey().deriveRegistrationLock());
|
||||
|
||||
String localPinHash = PinHashing.localPinHash(pin);
|
||||
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
|
||||
String localPinHash = PinHashUtil.localPinHash(pin);
|
||||
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorT
|
||||
decryptedGroupState
|
||||
)
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(group)
|
||||
val groupRecipient = Recipient.externalGroupExact(group!!)
|
||||
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val insertResult = MmsHelper.insert(
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
@@ -155,6 +156,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun markKyberPreKeyUsed(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
|
||||
@@ -48,7 +48,7 @@ object FakeClientHelpers {
|
||||
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
|
||||
@@ -31,7 +31,7 @@ object GroupTestingUtils {
|
||||
.setTitle(MessageContentFuzzer.string())
|
||||
.build()
|
||||
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
|
||||
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
|
||||
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.protocol.util.Medium
|
||||
import org.signal.libsignal.svr2.PinHash
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -19,7 +20,6 @@ import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.test.BuildConfig
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -97,7 +97,7 @@ object MockProvider {
|
||||
|
||||
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
|
||||
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
|
||||
override fun restorePin(hashedPin: HashedPin?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
override fun restorePin(hashedPin: PinHash?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
}
|
||||
|
||||
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
|
||||
|
||||
@@ -257,6 +257,307 @@
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltYellow"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_yellow"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_yellow" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_yellow" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltBubbles"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_bubbles"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_bubbles" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_bubbles" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltChat"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_chat"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_chat" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_chat" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltNews"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_news"
|
||||
android:label="@string/app_icon_label_news"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_news" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_news" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltNotes"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_notes"
|
||||
android:label="@string/app_icon_label_notes"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_notes" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_notes" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltColor"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_color"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_color" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_color" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltDark"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_dark"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltDarkVariant"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_dark_variant"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWhite"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_signal_white"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_white" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_signal_white" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWaves"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_waves"
|
||||
android:label="@string/app_icon_label_waves"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_waves" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_waves" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".RoutingActivityAltWeather"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_alt_weather"
|
||||
android:label="@string/app_icon_label_weather"
|
||||
android:targetActivity=".MainActivity">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_weather" />
|
||||
<meta-data
|
||||
android:name="com.sec.minimode.icon.landscape.normal"
|
||||
android:resource="@mipmap/ic_launcher_alt_weather" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
@@ -298,6 +599,14 @@
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="signal.link" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".conversation.v2.ConversationActivity"
|
||||
@@ -364,6 +673,11 @@
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".calls.links.details.CallLinkDetailsActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".calls.new.NewCallActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,7 @@ object AppCapabilities {
|
||||
@JvmStatic
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
uuid = false,
|
||||
gv2 = true,
|
||||
storage = storageCapable,
|
||||
gv1Migration = true,
|
||||
senderKey = true,
|
||||
announcementGroup = true,
|
||||
changeNumber = true,
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
@@ -211,6 +212,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
@@ -115,5 +115,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
|
||||
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
|
||||
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -26,7 +25,6 @@ import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
@@ -82,6 +80,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
handleProxyInIntent(getIntent());
|
||||
handleSignalMeIntent(getIntent());
|
||||
handleCallLinkInIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
@@ -148,14 +147,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
} else {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
|
||||
conversationListTabsViewModel.onChatsSelected();
|
||||
}
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
}
|
||||
|
||||
public @NonNull MainNavigator getNavigator() {
|
||||
@@ -183,6 +176,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public void onFirstRender() {
|
||||
onFirstRender = true;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class AudioRecorder {
|
||||
}
|
||||
recorder.start(fds[1]);
|
||||
this.recordingSubject = recordingSingle;
|
||||
} catch (IOException e) {
|
||||
} catch (IOException | RuntimeException e) {
|
||||
recordingSingle.onError(e);
|
||||
recorder = null;
|
||||
Log.w(TAG, e);
|
||||
|
||||
@@ -50,6 +50,7 @@ private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoic
|
||||
}
|
||||
|
||||
override fun disconnectBluetoothScoConnection() {
|
||||
Log.d(TAG, "Clearing call manager communication device.")
|
||||
ApplicationDependencies.getAndroidCallAudioManager().clearCommunicationDevice()
|
||||
}
|
||||
|
||||
@@ -80,46 +81,31 @@ private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: (
|
||||
private var hasWarnedAboutBluetooth = false
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT < 31) {
|
||||
audioHandler.post {
|
||||
signalBluetoothManager.start()
|
||||
Log.d(TAG, "Bluetooth manager started.")
|
||||
}
|
||||
audioHandler.post {
|
||||
signalBluetoothManager.start()
|
||||
Log.d(TAG, "Bluetooth manager started.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectBluetoothScoConnection() {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
|
||||
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
|
||||
if (device != null) {
|
||||
val result: Boolean = audioManager.setCommunicationDevice(device)
|
||||
if (result) {
|
||||
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
|
||||
} else {
|
||||
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
|
||||
audioHandler.post {
|
||||
if (signalBluetoothManager.state.shouldUpdate()) {
|
||||
Log.d(TAG, "Bluetooth manager updating devices.")
|
||||
signalBluetoothManager.updateDevice()
|
||||
}
|
||||
listener()
|
||||
} else {
|
||||
audioHandler.post {
|
||||
if (signalBluetoothManager.state.shouldUpdate()) {
|
||||
signalBluetoothManager.updateDevice()
|
||||
}
|
||||
val currentState = signalBluetoothManager.state
|
||||
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
|
||||
signalBluetoothManager.startScoAudio()
|
||||
} else {
|
||||
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
|
||||
uiThreadHandler.post {
|
||||
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
|
||||
bluetoothPermissionDeniedHandler()
|
||||
hasWarnedAboutBluetooth = true
|
||||
}
|
||||
listener()
|
||||
val currentState = signalBluetoothManager.state
|
||||
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
|
||||
Log.d(TAG, "Bluetooth manager state is AVAILABLE. Starting SCO connection.")
|
||||
signalBluetoothManager.startScoAudio()
|
||||
} else {
|
||||
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
|
||||
uiThreadHandler.post {
|
||||
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
|
||||
Log.d(TAG, "Warning about Bluetooth permissions.")
|
||||
bluetoothPermissionDeniedHandler()
|
||||
hasWarnedAboutBluetooth = true
|
||||
}
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,83 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
* Utility object for call links to try to keep some common logic in one place.
|
||||
*/
|
||||
object CallLinks {
|
||||
fun url(identifier: String) = "https://calls.signal.org/#$identifier"
|
||||
private const val ROOT_KEY = "key"
|
||||
|
||||
private val TAG = Log.tag(CallLinks::class.java)
|
||||
|
||||
fun url(linkKeyBytes: ByteArray) = "https://signal.link/call/#key=${Hex.dump(linkKeyBytes)}"
|
||||
|
||||
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
|
||||
return Observable.create { emitter ->
|
||||
|
||||
fun refresh() {
|
||||
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
|
||||
if (callLink != null) {
|
||||
emitter.onNext(callLink)
|
||||
}
|
||||
}
|
||||
|
||||
val observer = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerCallLinkObserver(roomId, observer)
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parseUrl(url: String): CallLinkRootKey? {
|
||||
val parts = url.split("#")
|
||||
if (parts.size != 2) {
|
||||
Log.w(TAG, "Invalid fragment delimiter count in url.")
|
||||
return null
|
||||
}
|
||||
|
||||
val fragment = parts[1]
|
||||
val fragmentParts = fragment.split("&")
|
||||
val fragmentQuery = fragmentParts.associate {
|
||||
val kv = it.split("=")
|
||||
if (kv.size != 2) {
|
||||
Log.w(TAG, "Invalid fragment keypair. Skipping.")
|
||||
}
|
||||
|
||||
val key = URLDecoder.decode(kv[0], "utf8")
|
||||
val value = URLDecoder.decode(kv[1], "utf8")
|
||||
|
||||
key to value
|
||||
}
|
||||
|
||||
val key = fragmentQuery[ROOT_KEY]
|
||||
if (key == null) {
|
||||
Log.w(TAG, "Root key not found in fragment query string.")
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO Parse the key into a byte array
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import android.app.Dialog
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -30,21 +35,34 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import java.time.Instant
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val avatarColor = remember { AvatarColor.random() }
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials.generate()
|
||||
CallLinkTable.CallLink(
|
||||
name = "Call Name",
|
||||
identifier = "blahblahblah",
|
||||
avatarColor = avatarColor,
|
||||
isApprovalRequired = false
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
|
||||
expiration = Instant.MAX,
|
||||
revoked = false
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
}
|
||||
SignalTheme(false) {
|
||||
@@ -95,10 +113,10 @@ fun SignalCallRow(
|
||||
.align(CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = callLink.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
)
|
||||
Text(
|
||||
text = CallLinks.url(callLink.identifier),
|
||||
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
/**
|
||||
* Repository for performing update operations on call links:
|
||||
* <ul>
|
||||
* <li>Set name</li>
|
||||
* <li>Set restrictions</li>
|
||||
* <li>Revoke link</li>
|
||||
* </ul>
|
||||
*
|
||||
* All of these will delegate to the [SignalCallLinkManager] but will additionally update the database state.
|
||||
*/
|
||||
class UpdateCallLinkRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun setCallName(credentials: CallLinkCredentials, name: String): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkName(
|
||||
credentials = credentials,
|
||||
name = name
|
||||
)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkRestrictions(
|
||||
credentials = credentials,
|
||||
restrictions = restrictions
|
||||
)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
|
||||
return callLinkManager
|
||||
.updateCallLinkRevoked(credentials, true)
|
||||
.doOnSuccess(updateState(credentials))
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
|
||||
return { result ->
|
||||
if (result is UpdateCallLinkResult.Success) {
|
||||
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
@@ -28,9 +33,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
@@ -39,7 +48,10 @@ import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
/**
|
||||
@@ -47,14 +59,20 @@ import org.thoughtcrime.securesms.util.Util
|
||||
*/
|
||||
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CreateCallLinkBottomSheetDialogFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: CreateCallLinkViewModel by viewModels()
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
|
||||
if (bundle.containsKey(resultKey)) {
|
||||
viewModel.setCallName(bundle.getString(resultKey)!!)
|
||||
setCallName(bundle.getString(resultKey)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,10 +112,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = callLink.isApprovalRequired,
|
||||
checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
|
||||
onCheckChanged = viewModel::setApproveAllMembers,
|
||||
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
|
||||
onCheckChanged = this@CreateCallLinkBottomSheetDialogFragment::setApproveAllMembers,
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::toggleApproveAllMembers)
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
@@ -133,54 +151,133 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCallName(callName: String) {
|
||||
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link name")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link restrictions")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun toggleApproveAllMembers() {
|
||||
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to update call link restrictions")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onAddACallNameClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
findNavController().navigate(
|
||||
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.name)
|
||||
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.state.name)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onJoinClicked() {
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
CommunicationActions.startVideoCall(requireActivity(), it.recipient)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onDoneClicked() {
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onShareViaSignalClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.build()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(snapshot.identifier)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
)
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onCopyLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(snapshot.identifier))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
|
||||
}
|
||||
}, onError = this::handleError)
|
||||
}
|
||||
|
||||
private fun onShareLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(snapshot.identifier))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
is EnsureCallLinkCreatedResult.Failure -> {
|
||||
Log.w(TAG, "Failed to create link: $it")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
|
||||
Log.w(TAG, "Failed to create call link: $failure")
|
||||
toastFailure()
|
||||
}
|
||||
|
||||
private fun handleError(throwable: Throwable) {
|
||||
Log.w(TAG, "Failed to create call link.", throwable)
|
||||
toastFailure()
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
|
||||
/**
|
||||
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
|
||||
* but will also ensure the database is updated.
|
||||
*/
|
||||
class CreateCallLinkRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun ensureCallLinkCreated(credentials: CallLinkCredentials, avatarColor: AvatarColor): Single<EnsureCallLinkCreatedResult> {
|
||||
val callLinkRecipientId = Single.fromCallable {
|
||||
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId)
|
||||
}
|
||||
|
||||
return callLinkRecipientId.flatMap { recipientId ->
|
||||
if (recipientId.isPresent) {
|
||||
Single.just(EnsureCallLinkCreatedResult.Success(Recipient.resolved(recipientId.get())))
|
||||
} else {
|
||||
callLinkManager.createCallLink(credentials).map {
|
||||
when (it) {
|
||||
is CreateCallLinkResult.Success -> {
|
||||
SignalDatabase.callLinks.insertCallLink(
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = it.state,
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
)
|
||||
|
||||
EnsureCallLinkCreatedResult.Success(
|
||||
Recipient.resolved(
|
||||
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,98 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import java.time.Instant
|
||||
|
||||
class CreateCallLinkViewModel : ViewModel() {
|
||||
class CreateCallLinkViewModel(
|
||||
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
|
||||
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
) : ViewModel() {
|
||||
private val credentials = CallLinkCredentials.generate()
|
||||
private val avatarColor = AvatarColor.random()
|
||||
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
|
||||
CallLinkTable.CallLink("", "", AvatarColor.random(), false)
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "",
|
||||
restrictions = Restrictions.NONE,
|
||||
revoked = false,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
)
|
||||
|
||||
val callLink: State<CallLinkTable.CallLink> = _callLink
|
||||
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
_callLink.value = _callLink.value.copy(isApprovalRequired = approveAllMembers)
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposables += CallLinks.watchCallLink(credentials.roomId)
|
||||
.subscribeBy {
|
||||
_callLink.value = it
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers() {
|
||||
_callLink.value = _callLink.value.copy(isApprovalRequired = _callLink.value.isApprovalRequired)
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun setCallName(callName: String) {
|
||||
_callLink.value = _callLink.value.copy(name = callName)
|
||||
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
|
||||
return repository.ensureCallLinkCreated(credentials, avatarColor)
|
||||
}
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
return commitCallLink()
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
|
||||
credentials,
|
||||
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
|
||||
)
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
|
||||
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
|
||||
}
|
||||
|
||||
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
|
||||
return commitCallLink()
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
|
||||
credentials,
|
||||
callName
|
||||
)
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.create
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
|
||||
sealed interface EnsureCallLinkCreatedResult {
|
||||
data class Success(val recipient: Recipient) : EnsureCallLinkCreatedResult
|
||||
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
||||
class CallLinkDetailsActivity : FragmentWrapperActivity() {
|
||||
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details)
|
||||
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
|
||||
|
||||
companion object {
|
||||
|
||||
private const val BUNDLE = "bundle"
|
||||
|
||||
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
|
||||
return Intent(context, CallLinkDetailsActivity::class.java)
|
||||
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -16,19 +24,34 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Provides detailed info about a call link and allows the owner of that link
|
||||
@@ -36,53 +59,115 @@ import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
*/
|
||||
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
|
||||
|
||||
private val viewModel: CallLinkViewModel by viewModels()
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
|
||||
}
|
||||
|
||||
private val args: CallLinkDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
|
||||
CallLinkDetailsViewModel.Factory(args.roomId)
|
||||
})
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
|
||||
if (bundle.containsKey(resultKey)) {
|
||||
viewModel.setName(bundle.getString(resultKey)!!)
|
||||
setName(bundle.getString(resultKey)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val isLoading by viewModel.isLoading
|
||||
val callLink by viewModel.callLink
|
||||
val state by viewModel.state
|
||||
|
||||
CallLinkDetails(
|
||||
isLoading,
|
||||
callLink,
|
||||
state,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
findNavController().popBackStack()
|
||||
ActivityCompat.finishAfterTransition(requireActivity())
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
// TODO("Not yet implemented")
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(this, recipientSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
val name = viewModel.callLink.value.name
|
||||
val name = viewModel.nameSnapshot
|
||||
findNavController().navigate(
|
||||
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
// TODO("Not yet implemented")
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
// TODO("Not yet implemented")
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to revoke. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}, onError = handleError("onDeleteClicked"))
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
// TODO("Not yet implemented")
|
||||
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to change restrictions. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = handleError("onApproveAllMembersChanged"))
|
||||
}
|
||||
|
||||
private fun setName(name: String) {
|
||||
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Success) {
|
||||
Log.w(TAG, "Failed to set name. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = handleError("setName"))
|
||||
}
|
||||
|
||||
private fun handleError(method: String): (throwable: Throwable) -> Unit {
|
||||
return {
|
||||
Log.w(TAG, "Failure during $method", it)
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +177,8 @@ private interface CallLinkDetailsCallback {
|
||||
fun onEditNameClicked()
|
||||
fun onShareClicked()
|
||||
fun onDeleteClicked()
|
||||
fun onDeleteConfirmed()
|
||||
fun onDeleteCanceled()
|
||||
fun onApproveAllMembersChanged(checked: Boolean)
|
||||
}
|
||||
|
||||
@@ -103,19 +190,30 @@ private fun CallLinkDetailsPreview() {
|
||||
}
|
||||
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials.generate()
|
||||
CallLinkTable.CallLink(
|
||||
name = "Call Name",
|
||||
identifier = "call-id-1",
|
||||
isApprovalRequired = false,
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = credentials.roomId,
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
avatarColor = avatarColor
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme(false) {
|
||||
CallLinkDetails(
|
||||
false,
|
||||
callLink,
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
object : CallLinkDetailsCallback {
|
||||
override fun onDeleteConfirmed() = Unit
|
||||
override fun onDeleteCanceled() = Unit
|
||||
override fun onNavigationClicked() = Unit
|
||||
override fun onJoinClicked() = Unit
|
||||
override fun onEditNameClicked() = Unit
|
||||
@@ -129,8 +227,7 @@ private fun CallLinkDetailsPreview() {
|
||||
|
||||
@Composable
|
||||
private fun CallLinkDetails(
|
||||
isLoading: Boolean,
|
||||
callLink: CallLinkTable.CallLink,
|
||||
state: CallLinkDetailsState,
|
||||
callback: CallLinkDetailsCallback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
@@ -138,13 +235,13 @@ private fun CallLinkDetails(
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
|
||||
) { paddingValues ->
|
||||
if (isLoading) {
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
SignalCallRow(
|
||||
callLink = callLink,
|
||||
callLink = state.callLink,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
@@ -155,7 +252,7 @@ private fun CallLinkDetails(
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = callLink.isApprovalRequired,
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged
|
||||
)
|
||||
@@ -175,5 +272,16 @@ private fun CallLinkDetails(
|
||||
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
|
||||
class CallLinkDetailsRepository(
|
||||
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
|
||||
) {
|
||||
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
|
||||
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
|
||||
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, result.callLinkState)
|
||||
is ReadCallLinkResult.Failure -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun watchCallLinkRecipient(callLinkRoomId: CallLinkRoomId): Observable<Recipient> {
|
||||
return Maybe.fromCallable<RecipientId> { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() }
|
||||
.flatMapObservable { Recipient.observable(it) }
|
||||
.distinctUntilChanged { a, b -> a.hasSameContent(b) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
|
||||
@Immutable
|
||||
data class CallLinkDetailsState(
|
||||
val displayRevocationDialog: Boolean = false,
|
||||
val callLink: CallLinkTable.CallLink? = null
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
class CallLinkDetailsViewModel(
|
||||
callLinkRoomId: CallLinkRoomId,
|
||||
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
|
||||
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
) : ViewModel() {
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
|
||||
val state: State<CallLinkDetailsState> = _state
|
||||
val nameSnapshot: String
|
||||
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
|
||||
|
||||
val rootKeySnapshot: ByteArray
|
||||
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
|
||||
|
||||
private val recipientSubject = BehaviorSubject.create<Recipient>()
|
||||
val recipientSnapshot: Recipient?
|
||||
get() = recipientSubject.value
|
||||
|
||||
init {
|
||||
disposables += repository.refreshCallLinkState(callLinkRoomId)
|
||||
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
|
||||
_state.value = _state.value.copy(callLink = it)
|
||||
}
|
||||
|
||||
disposables += repository
|
||||
.watchCallLinkRecipient(callLinkRoomId)
|
||||
.subscribeBy(onNext = recipientSubject::onNext)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) {
|
||||
_state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog)
|
||||
}
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
|
||||
}
|
||||
|
||||
fun setName(name: String): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.setCallName(credentials, name)
|
||||
}
|
||||
|
||||
fun revoke(): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.revokeCallLink(credentials)
|
||||
}
|
||||
|
||||
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
|
||||
class CallLinkViewModel : ViewModel() {
|
||||
private val isLoadingState: MutableState<Boolean> = mutableStateOf(true)
|
||||
val isLoading: State<Boolean> = isLoadingState
|
||||
|
||||
private val callLinkState: MutableState<CallLinkTable.CallLink> = mutableStateOf(
|
||||
CallLinkTable.CallLink("", "", AvatarColor.A120, false)
|
||||
)
|
||||
val callLink: State<CallLinkTable.CallLink> = callLinkState
|
||||
|
||||
fun setName(name: String) {
|
||||
callLinkState.value = callLinkState.value.copy(name = name)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.text.style.TextAppearanceSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBin
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.SearchUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
@@ -60,6 +62,14 @@ class CallLogAdapter(
|
||||
inflater = CallLogCreateCallLinkItemBinding::inflate
|
||||
)
|
||||
)
|
||||
|
||||
registerFactory(
|
||||
CallLinkModel::class.java,
|
||||
BindingFactory(
|
||||
creator = { CallLinkModelViewHolder(it, callbacks::onCallLinkClicked, callbacks::onCallLinkLongClicked, callbacks::onStartVideoCallClicked) },
|
||||
inflater = CallLogAdapterItemBinding::inflate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(
|
||||
@@ -74,6 +84,7 @@ class CallLogAdapter(
|
||||
.map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
|
||||
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
|
||||
}
|
||||
@@ -118,6 +129,44 @@ class CallLogAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private class CallLinkModel(
|
||||
val callLink: CallLogRow.CallLink,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallLinkModel> {
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink.record.roomId == newItem.callLink.record.roomId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink == newItem.callLink &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallLinkModel): Any? {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
CallModel.PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectionStateTheSame(newItem: CallLinkModel): Boolean {
|
||||
return selectionState.contains(callLink.id) == newItem.selectionState.contains(newItem.callLink.id) &&
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
}
|
||||
|
||||
private class ClearFilterModel : MappingModel<ClearFilterModel> {
|
||||
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
@@ -129,6 +178,54 @@ class CallLogAdapter(
|
||||
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CallLinkModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
|
||||
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
itemView.setOnClickListener {
|
||||
onCallLinkClicked(model.callLink)
|
||||
}
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onCallLinkLongClicked(itemView, model.callLink)
|
||||
}
|
||||
|
||||
itemView.isSelected = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.callRecipientAvatar.setAvatar(model.callLink.recipient)
|
||||
|
||||
val callLinkName = model.callLink.record.state.name.takeIf { it.isNotEmpty() }
|
||||
?: context.getString(R.string.WebRtcCallView__signal_call)
|
||||
|
||||
binding.callRecipientName.text = SearchUtil.getHighlightedSpan(
|
||||
Locale.getDefault(),
|
||||
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
|
||||
callLinkName,
|
||||
model.callLink.searchQuery,
|
||||
SearchUtil.MATCH_ALL
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
|
||||
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
|
||||
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient)
|
||||
}
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
@@ -153,13 +250,27 @@ class CallLogAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
|
||||
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
|
||||
presentRecipientDetails(model.call.peer, model.call.searchQuery)
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
presentCallType(model)
|
||||
}
|
||||
|
||||
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), recipient, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
|
||||
binding.callRecipientName.text = if (searchQuery != null) {
|
||||
SearchUtil.getHighlightedSpan(
|
||||
Locale.getDefault(),
|
||||
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
|
||||
recipient.getDisplayName(context),
|
||||
searchQuery,
|
||||
SearchUtil.MATCH_ALL
|
||||
)
|
||||
} else {
|
||||
recipient.getDisplayName(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
|
||||
val callState = context.getString(getCallStateStringRes(call.record))
|
||||
binding.callInfo.text = context.getString(
|
||||
@@ -181,7 +292,7 @@ class CallLogAdapter(
|
||||
if (call.record.event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
R.color.signal_colorOnSurfaceVariant
|
||||
}
|
||||
)
|
||||
|
||||
@@ -219,6 +330,7 @@ class CallLogAdapter(
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
|
||||
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
|
||||
binding.callType.visible = false
|
||||
binding.groupCallButton.visible = true
|
||||
@@ -249,6 +361,7 @@ class CallLogAdapter(
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
else -> error("Unexpected type ${call.type}")
|
||||
}
|
||||
}
|
||||
@@ -269,6 +382,7 @@ class CallLogAdapter(
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
else -> error("Unexpected type ${call.messageType}")
|
||||
}
|
||||
}
|
||||
@@ -308,11 +422,21 @@ class CallLogAdapter(
|
||||
*/
|
||||
fun onCallClicked(callLogRow: CallLogRow.Call)
|
||||
|
||||
/**
|
||||
* Invoked when a call link row is clicked
|
||||
*/
|
||||
fun onCallLinkClicked(callLogRow: CallLogRow.CallLink)
|
||||
|
||||
/**
|
||||
* Invoked when a call row is long-clicked
|
||||
*/
|
||||
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when a call link row is long-clicked
|
||||
*/
|
||||
fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when the clear filter button is pressed
|
||||
*/
|
||||
@@ -321,11 +445,11 @@ class CallLogAdapter(
|
||||
/**
|
||||
* Invoked when user presses the audio icon
|
||||
*/
|
||||
fun onStartAudioCallClicked(peer: Recipient)
|
||||
fun onStartAudioCallClicked(recipient: Recipient)
|
||||
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(peer: Recipient)
|
||||
fun onStartVideoCallClicked(recipient: Recipient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
@@ -30,28 +32,47 @@ class CallLogContextMenu(
|
||||
}
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(call),
|
||||
getVideoCallActionItem(call.peer),
|
||||
getAudioCallActionItem(call),
|
||||
getGoToChatActionItem(call),
|
||||
getInfoActionItem(call),
|
||||
getInfoActionItem(call.peer, (call.id as CallLogRow.Id.Call).children.toLongArray()),
|
||||
getSelectActionItem(call),
|
||||
getDeleteActionItem(call)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
|
||||
fun show(recyclerView: RecyclerView, anchor: View, callLink: CallLogRow.CallLink) {
|
||||
recyclerView.suppressLayout(true)
|
||||
anchor.isSelected = true
|
||||
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.onDismiss {
|
||||
anchor.isSelected = false
|
||||
recyclerView.suppressLayout(false)
|
||||
}
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(callLink.recipient),
|
||||
getInfoActionItem(callLink.recipient, longArrayOf()),
|
||||
getSelectActionItem(callLink),
|
||||
getDeleteActionItem(callLink)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
|
||||
// TODO [alex] -- Need group calling disposition to make this correct
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_video_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__video_call)
|
||||
) {
|
||||
CommunicationActions.startVideoCall(fragment, call.peer)
|
||||
CommunicationActions.startVideoCall(fragment, peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.peer.isGroup) {
|
||||
if (call.peer.isCallLink || call.peer.isGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -63,26 +84,32 @@ class CallLogContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
return when {
|
||||
call.peer.isCallLink -> null
|
||||
else -> ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
|
||||
private fun getInfoActionItem(peer: Recipient, messageIds: LongArray): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
|
||||
val intent = when {
|
||||
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
|
||||
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
|
||||
}
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
|
||||
private fun getSelectActionItem(call: CallLogRow): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__select)
|
||||
@@ -91,8 +118,8 @@ class CallLogContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.record.event == CallTable.Event.ONGOING) {
|
||||
private fun getDeleteActionItem(call: CallLogRow): ActionItem? {
|
||||
if (call is CallLogRow.Call && call.record.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -105,7 +132,7 @@ class CallLogContextMenu(
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow.Call)
|
||||
fun deleteCall(call: CallLogRow.Call)
|
||||
fun startSelection(call: CallLogRow)
|
||||
fun deleteCall(call: CallLogRow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
@@ -311,9 +312,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun onCallClicked(callLogRow: CallLogRow.Call) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
|
||||
} else if (!callLogRow.peer.isCallLink) {
|
||||
val intent = ConversationSettingsActivity.forCall(
|
||||
requireContext(),
|
||||
callLogRow.peer,
|
||||
(callLogRow.id as CallLogRow.Id.Call).children.toLongArray()
|
||||
)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallLinkClicked(callLogRow: CallLogRow.CallLink) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,25 +337,30 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean {
|
||||
callLogContextMenu.show(binding.recycler, itemView, callLinkLogRow)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearFilterClicked() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun onStartAudioCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, peer)
|
||||
override fun onStartAudioCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, recipient)
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, peer)
|
||||
override fun onStartVideoCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, recipient)
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow.Call) {
|
||||
override fun startSelection(call: CallLogRow) {
|
||||
callLogActionMode.start()
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
override fun deleteCall(call: CallLogRow.Call) {
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
|
||||
@@ -12,28 +12,62 @@ class CallLogPagedDataSource(
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
|
||||
|
||||
private var callsCount = 0
|
||||
private var callEventsCount = 0
|
||||
private var callLinksCount = 0
|
||||
|
||||
override fun size(): Int {
|
||||
callsCount = repository.getCallsCount(query, filter)
|
||||
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
|
||||
callEventsCount = repository.getCallsCount(query, filter)
|
||||
callLinksCount = repository.getCallLinksCount(query, filter)
|
||||
return callEventsCount + callLinksCount + hasFilter.toInt() + hasCallLinkRow.toInt()
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val calls = mutableListOf<CallLogRow>()
|
||||
val callLimit = length - hasCallLinkRow.toInt()
|
||||
|
||||
if (start == 0 && length >= 1 && hasCallLinkRow) {
|
||||
calls.add(CallLogRow.CreateCallLink)
|
||||
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val callLogRows = mutableListOf<CallLogRow>()
|
||||
if (length <= 0) {
|
||||
return callLogRows
|
||||
}
|
||||
|
||||
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
|
||||
val callLinkStart = if (hasCallLinkRow) 1 else 0
|
||||
val callEventStart = callLinkStart + callLinksCount
|
||||
val clearFilterStart = callEventStart + callEventsCount
|
||||
|
||||
if (calls.size < length && hasFilter) {
|
||||
calls.add(CallLogRow.ClearFilter)
|
||||
var remaining = length
|
||||
if (start < callLinkStart) {
|
||||
callLogRows.add(CallLogRow.CreateCallLink)
|
||||
remaining -= 1
|
||||
}
|
||||
|
||||
return calls
|
||||
if (start < callEventStart && remaining > 0) {
|
||||
val callLinks = repository.getCallLinks(
|
||||
query,
|
||||
filter,
|
||||
start,
|
||||
remaining
|
||||
)
|
||||
|
||||
callLogRows.addAll(callLinks)
|
||||
|
||||
remaining -= callLinks.size
|
||||
}
|
||||
|
||||
if (start < clearFilterStart && remaining > 0) {
|
||||
val callEvents = repository.getCalls(
|
||||
query,
|
||||
filter,
|
||||
start - callLinksCount,
|
||||
remaining
|
||||
)
|
||||
|
||||
callLogRows.addAll(callEvents)
|
||||
|
||||
remaining -= callEvents.size
|
||||
}
|
||||
|
||||
if (start <= clearFilterStart && remaining > 0) {
|
||||
callLogRows.add(CallLogRow.ClearFilter)
|
||||
}
|
||||
|
||||
return callLogRows
|
||||
}
|
||||
|
||||
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
|
||||
@@ -47,5 +81,7 @@ class CallLogPagedDataSource(
|
||||
interface CallRepository {
|
||||
fun getCallsCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
|
||||
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
|
||||
return when (filter) {
|
||||
CallLogFilter.MISSED -> 0
|
||||
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinksCount(query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return when (filter) {
|
||||
CallLogFilter.MISSED -> emptyList()
|
||||
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
SignalDatabase.messages.markAllCallEventsRead()
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
||||
/**
|
||||
* A row to be displayed in the call log
|
||||
@@ -11,6 +18,16 @@ sealed class CallLogRow {
|
||||
|
||||
abstract val id: Id
|
||||
|
||||
/**
|
||||
* A call link with no "active" events.
|
||||
*/
|
||||
data class CallLink(
|
||||
val record: CallLinkTable.CallLink,
|
||||
val recipient: Recipient,
|
||||
val searchQuery: String?,
|
||||
override val id: Id = Id.CallLink(record.roomId)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
@@ -20,6 +37,7 @@ sealed class CallLogRow {
|
||||
val date: Long,
|
||||
val groupCallState: GroupCallState,
|
||||
val children: Set<Long>,
|
||||
val searchQuery: String?,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
@@ -36,6 +54,7 @@ sealed class CallLogRow {
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val children: Set<Long>) : Id()
|
||||
data class CallLink(val roomId: CallLinkRoomId) : Id()
|
||||
object ClearFilter : Id()
|
||||
object CreateCallLink : Id()
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class CallLogViewModel(
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stageCallDeletion(call: CallLogRow.Call) {
|
||||
fun stageCallDeletion(call: CallLogRow) {
|
||||
callLogStore.state.stagedDeletion?.commit()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
|
||||
@@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
}
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
|
||||
@@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
|
||||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
}
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.request.target.DrawableImageViewTarget;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
|
||||
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
|
||||
|
||||
private static final String TAG = Log.tag(GlideDrawableListeningTarget.class);
|
||||
|
||||
private final SettableFuture<Boolean> loaded;
|
||||
|
||||
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
|
||||
@@ -21,6 +24,12 @@ public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
|
||||
|
||||
@Override
|
||||
protected void setResource(@Nullable Drawable resource) {
|
||||
if (resource == null) {
|
||||
Log.d(TAG, "Loaded null resource");
|
||||
} else {
|
||||
Log.d(TAG, "Loaded resource of w " + resource.getIntrinsicWidth() + " by h " + resource.getIntrinsicHeight());
|
||||
}
|
||||
|
||||
super.setResource(resource);
|
||||
loaded.set(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.EditText
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our
|
||||
* own input fragment.
|
||||
*/
|
||||
class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var inputId: Int? = null
|
||||
private var input: Fragment? = null
|
||||
|
||||
lateinit var fragmentManager: FragmentManager
|
||||
var listener: Listener? = null
|
||||
|
||||
fun showSoftkey(editText: EditText) {
|
||||
ViewUtil.focusAndShowKeyboard(editText)
|
||||
hideInput(resetKeyboardGuideline = false)
|
||||
}
|
||||
|
||||
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, toggled: (Boolean) -> Unit = { }) {
|
||||
if (fragmentCreator.id == inputId) {
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
toggled(false)
|
||||
} else {
|
||||
hideInput(resetKeyboardGuideline = false)
|
||||
showInput(fragmentCreator, imeTarget)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideInput() {
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
}
|
||||
|
||||
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {
|
||||
inputId = fragmentCreator.id
|
||||
input = fragmentCreator.create()
|
||||
|
||||
fragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.input_container, input!!)
|
||||
.commit()
|
||||
|
||||
overrideKeyboardGuidelineWithPreviousHeight()
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
|
||||
listener?.onInputShown()
|
||||
}
|
||||
|
||||
private fun hideInput(resetKeyboardGuideline: Boolean) {
|
||||
val inputHidden = input != null
|
||||
input?.let {
|
||||
fragmentManager
|
||||
.beginTransaction()
|
||||
.remove(it)
|
||||
.commit()
|
||||
}
|
||||
input = null
|
||||
inputId = null
|
||||
|
||||
if (resetKeyboardGuideline) {
|
||||
resetKeyboardGuideline()
|
||||
} else {
|
||||
clearKeyboardGuidelineOverride()
|
||||
}
|
||||
|
||||
if (inputHidden) {
|
||||
listener?.onInputHidden()
|
||||
}
|
||||
}
|
||||
|
||||
interface FragmentCreator {
|
||||
val id: Int
|
||||
fun create(): Fragment
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onInputShown()
|
||||
fun onInputHidden()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import android.view.animation.AnimationSet;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.TranslateAnimation;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@@ -30,6 +31,8 @@ import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftTable;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
@@ -52,9 +56,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -94,8 +100,9 @@ public class InputPanel extends LinearLayout
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private ViewGroup composeContainer;
|
||||
private View editMessageLabel;
|
||||
private View editMessageCancel;
|
||||
private ImageView editMessageThumbnail;
|
||||
private View editMessageHeader;
|
||||
|
||||
private MicrophoneRecorderView microphoneRecorderView;
|
||||
private SlideToCancel slideToCancel;
|
||||
@@ -153,8 +160,9 @@ public class InputPanel extends LinearLayout
|
||||
findViewById(R.id.microphone),
|
||||
TimeUnit.HOURS.toSeconds(1),
|
||||
() -> microphoneRecorderView.cancelAction(false));
|
||||
this.editMessageLabel = findViewById(R.id.edit_message);
|
||||
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
|
||||
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
|
||||
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
|
||||
|
||||
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
|
||||
|
||||
@@ -385,22 +393,43 @@ public class InputPanel extends LinearLayout
|
||||
quoteView.setWallpaperEnabled(enabled);
|
||||
}
|
||||
|
||||
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
|
||||
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
|
||||
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft) {
|
||||
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
|
||||
if (!fromDraft) {
|
||||
composeText.setText(textToEdit);
|
||||
composeText.setSelection(textToEdit.length());
|
||||
}
|
||||
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
|
||||
Quote quote = MessageRecordUtil.getQuote(conversationMessageToEdit.getMessageRecord());
|
||||
if (quote == null) {
|
||||
clearQuote();
|
||||
} else {
|
||||
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
|
||||
}
|
||||
this.messageToEdit = messageToEdit.getMessageRecord();
|
||||
this.messageToEdit = conversationMessageToEdit.getMessageRecord();
|
||||
updateEditModeThumbnail(glideRequests);
|
||||
updateEditModeUi();
|
||||
}
|
||||
|
||||
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
|
||||
if (messageToEdit instanceof MediaMmsMessageRecord) {
|
||||
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
|
||||
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
editMessageThumbnail.setVisibility(VISIBLE);
|
||||
glideRequests.load(new DecryptableStreamUriLoader.DecryptableUri(imageVideoSlide.getUri()))
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(editMessageThumbnail);
|
||||
} else {
|
||||
editMessageThumbnail.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
editMessageThumbnail.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void exitEditMessageMode() {
|
||||
if (messageToEdit != null) {
|
||||
composeText.setText("");
|
||||
@@ -413,13 +442,13 @@ public class InputPanel extends LinearLayout
|
||||
private void updateEditModeUi() {
|
||||
if (inEditMessageMode()) {
|
||||
ViewUtil.focusAndShowKeyboard(composeText);
|
||||
editMessageLabel.setVisibility(View.VISIBLE);
|
||||
editMessageHeader.setVisibility(View.VISIBLE);
|
||||
editMessageCancel.setVisibility(View.VISIBLE);
|
||||
if (listener != null) {
|
||||
listener.onEnterEditMode();
|
||||
}
|
||||
} else {
|
||||
editMessageLabel.setVisibility(View.GONE);
|
||||
editMessageHeader.setVisibility(View.GONE);
|
||||
editMessageCancel.setVisibility(View.GONE);
|
||||
if (listener != null) {
|
||||
listener.onExitEditMode();
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.WindowInsets;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class InsetAwareConstraintLayout extends ConstraintLayout {
|
||||
|
||||
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
|
||||
private Insets insets;
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
|
||||
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
|
||||
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
|
||||
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
|
||||
|
||||
applyInsets(newInsets);
|
||||
return super.onApplyWindowInsets(insets);
|
||||
}
|
||||
|
||||
public void applyInsets(@NonNull Insets insets) {
|
||||
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
|
||||
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
|
||||
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
|
||||
Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline);
|
||||
|
||||
if (statusBarGuideline != null) {
|
||||
statusBarGuideline.setGuidelineBegin(insets.top);
|
||||
}
|
||||
|
||||
if (navigationBarGuideline != null) {
|
||||
navigationBarGuideline.setGuidelineEnd(insets.bottom);
|
||||
}
|
||||
|
||||
if (parentStartGuideline != null) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
parentStartGuideline.setGuidelineBegin(insets.left);
|
||||
} else {
|
||||
parentStartGuideline.setGuidelineBegin(insets.right);
|
||||
}
|
||||
}
|
||||
|
||||
if (parentEndGuideline != null) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
parentEndGuideline.setGuidelineEnd(insets.right);
|
||||
} else {
|
||||
parentEndGuideline.setGuidelineEnd(insets.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface WindowInsetsTypeProvider {
|
||||
|
||||
WindowInsetsTypeProvider ALL = () ->
|
||||
WindowInsetsCompat.Type.ime() |
|
||||
WindowInsetsCompat.Type.systemBars() |
|
||||
WindowInsetsCompat.Type.displayCutout();
|
||||
|
||||
@WindowInsetsCompat.Type.InsetsType
|
||||
int getInsetsType();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Surface
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.Guideline
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
|
||||
* by the system. For improved backwards-compatibility we must use [ViewCompat] for configuring
|
||||
* the inset change callbacks.
|
||||
*
|
||||
* In portrait mode these are how the guidelines will be configured:
|
||||
*
|
||||
* - [R.id.status_bar_guideline] is set to the bottom of the status bar
|
||||
* - [R.id.navigation_bar_guideline] is set to the top of the navigation bar
|
||||
* - [R.id.parent_start_guideline] is set to the start of the parent
|
||||
* - [R.id.parent_end_guideline] is set to the end of the parent
|
||||
* - [R.id.keyboard_guideline] will be set to the top of the keyboard and will
|
||||
* change as the keyboard is shown or hidden
|
||||
*
|
||||
* In landscape, the spirit of the guidelines are maintained but their names may not
|
||||
* correlated exactly to the inset they are providing.
|
||||
*
|
||||
* These guidelines will only be updated if present in your layout, you can use
|
||||
* `<include layout="@layout/system_ui_guidelines" />` to quickly include them.
|
||||
*/
|
||||
@Suppress("LeakingThis")
|
||||
open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private val keyboardType = WindowInsetsCompat.Type.ime()
|
||||
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
}
|
||||
|
||||
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
|
||||
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
|
||||
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
|
||||
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
|
||||
|
||||
private val keyboardAnimator = KeyboardInsetAnimator()
|
||||
private val displayMetrics = DisplayMetrics()
|
||||
private var overridingKeyboard: Boolean = false
|
||||
|
||||
init {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
|
||||
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
|
||||
windowInsetsCompat
|
||||
}
|
||||
|
||||
if (attrs != null) {
|
||||
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
|
||||
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
|
||||
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
|
||||
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
|
||||
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
|
||||
|
||||
if (keyboardInsets.bottom > 0) {
|
||||
setKeyboardHeight(keyboardInsets.bottom)
|
||||
if (!keyboardAnimator.animating) {
|
||||
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
|
||||
} else {
|
||||
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
|
||||
}
|
||||
} else if (!overridingKeyboard) {
|
||||
if (!keyboardAnimator.animating) {
|
||||
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
} else {
|
||||
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun overrideKeyboardGuidelineWithPreviousHeight() {
|
||||
overridingKeyboard = true
|
||||
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
|
||||
}
|
||||
|
||||
protected fun clearKeyboardGuidelineOverride() {
|
||||
overridingKeyboard = false
|
||||
}
|
||||
|
||||
protected fun resetKeyboardGuideline() {
|
||||
clearKeyboardGuidelineOverride()
|
||||
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
|
||||
}
|
||||
|
||||
private fun getKeyboardHeight(): Int {
|
||||
val height = if (isLandscape()) {
|
||||
SignalStore.misc().keyboardLandscapeHeight
|
||||
} else {
|
||||
SignalStore.misc().keyboardPortraitHeight
|
||||
}
|
||||
|
||||
return if (height <= 0) {
|
||||
resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
|
||||
} else {
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeyboardHeight(height: Int) {
|
||||
if (isLandscape()) {
|
||||
SignalStore.misc().keyboardLandscapeHeight = height
|
||||
} else {
|
||||
SignalStore.misc().keyboardPortraitHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLandscape(): Boolean {
|
||||
val rotation = getDeviceRotation()
|
||||
return rotation == Surface.ROTATION_90
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getDeviceRotation(): Int {
|
||||
if (isInEditMode) {
|
||||
return Surface.ROTATION_0
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
context.display?.getRealMetrics(displayMetrics)
|
||||
} else {
|
||||
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
|
||||
}
|
||||
|
||||
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
|
||||
}
|
||||
|
||||
private val Guideline?.guidelineEnd: Int
|
||||
get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd
|
||||
|
||||
/**
|
||||
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
|
||||
*/
|
||||
private inner class KeyboardInsetAnimator : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
var animating = false
|
||||
private set
|
||||
|
||||
private var startingGuidelineEnd: Int = 0
|
||||
var endingGuidelineEnd: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
growing = value > startingGuidelineEnd
|
||||
}
|
||||
private var growing: Boolean = false
|
||||
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
if (overridingKeyboard) {
|
||||
return
|
||||
}
|
||||
|
||||
animating = true
|
||||
startingGuidelineEnd = keyboardGuideline.guidelineEnd
|
||||
}
|
||||
|
||||
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
|
||||
if (overridingKeyboard) {
|
||||
return insets
|
||||
}
|
||||
|
||||
val imeAnimation = runningAnimations.find { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }
|
||||
if (imeAnimation == null) {
|
||||
return insets
|
||||
}
|
||||
|
||||
val estimatedKeyboardHeight: Int = if (growing) {
|
||||
endingGuidelineEnd * imeAnimation.interpolatedFraction
|
||||
} else {
|
||||
startingGuidelineEnd * (1f - imeAnimation.interpolatedFraction)
|
||||
}.toInt()
|
||||
|
||||
if (growing) {
|
||||
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(startingGuidelineEnd))
|
||||
} else {
|
||||
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(endingGuidelineEnd))
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
if (overridingKeyboard) {
|
||||
return
|
||||
}
|
||||
|
||||
keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
|
||||
animating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.text.Spannable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
@@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
setMessageType(messageType);
|
||||
|
||||
bodyView.enableSpoilerFiltering();
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
.show(items)
|
||||
}
|
||||
|
||||
interface SendTypeChangedListener {
|
||||
fun interface SendTypeChangedListener {
|
||||
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Paint.FontMetricsInt;
|
||||
import android.graphics.drawable.Drawable;
|
||||
@@ -55,6 +56,10 @@ public class EmojiSpan extends AnimatingImageSpan {
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
|
||||
if (paint.getColor() == Color.TRANSPARENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
int height = bottom - top;
|
||||
int centeringMargin = (height - size) / 2;
|
||||
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
|
||||
|
||||
@@ -68,11 +68,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private TextDirectionHeuristic textDirection;
|
||||
private boolean isJumbomoji;
|
||||
private boolean forceJumboEmoji;
|
||||
private boolean isInOnDraw;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
@@ -113,15 +111,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
setText(getText());
|
||||
}
|
||||
|
||||
public void enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
isInOnDraw = true;
|
||||
|
||||
boolean hasSpannedText = getText() instanceof Spanned;
|
||||
boolean hasLayout = getLayout() != null;
|
||||
|
||||
@@ -134,8 +125,6 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
if (hasSpannedText && !hasLayout && getLayout() != null) {
|
||||
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
|
||||
}
|
||||
|
||||
isInOnDraw = false;
|
||||
}
|
||||
|
||||
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
|
||||
@@ -187,9 +176,6 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
|
||||
}
|
||||
super.setText(textToSet, BufferType.SPANNABLE);
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
@@ -333,11 +319,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
spoilerFilteringSpannableFactory.wrap(newTextToSet);
|
||||
}
|
||||
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
super.setText(newTextToSet, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +348,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
newContent.append(getText().subSequence(0, overflowStart).toString())
|
||||
.append(ellipsized.subSequence(0, ellipsized.length()).toString());
|
||||
|
||||
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
|
||||
if (newContent.length() > 0) {
|
||||
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
|
||||
}
|
||||
|
||||
newContent.append(Optional.ofNullable(overflowText).orElse(""));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
@@ -21,8 +20,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
private var bufferType: BufferType? = null
|
||||
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
|
||||
private val spoilerRendererDelegate: SpoilerRendererDelegate
|
||||
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
|
||||
private var isInOnDraw: Boolean = false
|
||||
|
||||
init {
|
||||
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
|
||||
@@ -30,8 +27,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
isInOnDraw = true
|
||||
|
||||
if (text is Spanned && layout != null) {
|
||||
val checkpoint = canvas.save()
|
||||
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
|
||||
@@ -41,9 +36,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
|
||||
isInOnDraw = false
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
@@ -69,10 +63,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
}
|
||||
|
||||
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
|
||||
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
|
||||
}
|
||||
|
||||
bufferType = BufferType.SPANNABLE
|
||||
super.setText(newContent, type)
|
||||
}
|
||||
@@ -86,9 +76,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.text.Spannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
|
||||
|
||||
/**
|
||||
* Spannable factory used to help ensure spans are copied/maintained properly through the
|
||||
* Android text handling system.
|
||||
*
|
||||
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
|
||||
*/
|
||||
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
|
||||
override fun newSpannable(source: CharSequence): Spannable {
|
||||
return wrap(super.newSpannable(source))
|
||||
}
|
||||
|
||||
fun wrap(source: Spannable): SpoilerFilteringSpannable {
|
||||
return SpoilerFilteringSpannable(source, inOnDraw)
|
||||
}
|
||||
}
|
||||
@@ -212,22 +212,21 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
|
||||
switchWidget.setOnCheckedChangeListener(null)
|
||||
|
||||
switchWidget.isChecked = model.isChecked
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
|
||||
switchWidget.setOnCheckedChangeListener { _, _ ->
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
if (payload.contains(SwitchPreference.PAYLOAD_CHECKED)) {
|
||||
return
|
||||
}
|
||||
|
||||
super.bind(model)
|
||||
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
|
||||
itemView.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.greenrobot.eventbus.ThreadMode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder
|
||||
import org.thoughtcrime.securesms.components.reminder.Reminder
|
||||
import org.thoughtcrime.securesms.components.reminder.ReminderView
|
||||
@@ -321,10 +322,14 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) {
|
||||
|
||||
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
|
||||
private val aboutView: TextView = itemView.findViewById(R.id.about)
|
||||
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
|
||||
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val qrButton: View = itemView.findViewById(R.id.qr_button)
|
||||
|
||||
init {
|
||||
aboutView.setOverflowText(" ")
|
||||
}
|
||||
|
||||
override fun bind(model: BioPreference) {
|
||||
super.bind(model)
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.PinHashing
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
import org.thoughtcrime.securesms.lock.v2.KbsConstants
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
@@ -39,6 +38,7 @@ import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil
|
||||
|
||||
class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) {
|
||||
|
||||
@@ -247,7 +247,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
pinEditText.typeface = Typeface.DEFAULT
|
||||
turnOffButton.setOnClickListener {
|
||||
val pin = pinEditText.text.toString()
|
||||
val correct = PinHashing.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
|
||||
val correct = PinHashUtil.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
|
||||
if (correct) {
|
||||
SignalStore.pinValues().setPinRemindersEnabled(false)
|
||||
viewModel.refreshState()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance
|
||||
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import org.signal.core.util.concurrent.observe
|
||||
@@ -67,6 +68,15 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
|
||||
}
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__app_icon),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__message_text_size),
|
||||
listItems = messageFontSizeLabels,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconPreset
|
||||
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class AppIconSelectionFragment : ComposeFragment() {
|
||||
private lateinit var appIconUtility: AppIconUtility
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
appIconUtility = AppIconUtility(context)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.preferences__app_icon),
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
IconSelectionScreen(appIconUtility.currentAppIcon, ::updateAppIcon, ::openLearnMore, Modifier.padding(contentPadding))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAppIcon(preset: AppIconPreset) {
|
||||
if (!appIconUtility.isCurrentlySelected(preset)) {
|
||||
appIconUtility.setNewAppIcon(preset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLearnMore() {
|
||||
findNavController().safeNavigate(R.id.action_appIconSelectionFragment_to_appIconTutorialFragment)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AppIconSelectionFragment::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LEARN_MORE_TAG = "learn_more"
|
||||
private const val URL_TAG = "URL"
|
||||
private const val COLUMN_COUNT = 4
|
||||
|
||||
/**
|
||||
* Screen allowing the user to view all the possible icon and select a new one to use.
|
||||
*/
|
||||
@Composable
|
||||
fun IconSelectionScreen(activeIcon: AppIconPreset, onItemConfirmed: (AppIconPreset) -> Unit, onWarningClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var showDialog: Boolean by remember { mutableStateOf(false) }
|
||||
var pendingIcon: AppIconPreset by remember {
|
||||
mutableStateOf(activeIcon)
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ChangeIconDialog(
|
||||
pendingIcon = pendingIcon,
|
||||
onConfirm = {
|
||||
onItemConfirmed(pendingIcon)
|
||||
showDialog = false
|
||||
},
|
||||
onDismiss = {
|
||||
pendingIcon = activeIcon
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
CaveatWarning(
|
||||
onClick = onWarningClick,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
enumValues<AppIconPreset>().toList().chunked(COLUMN_COUNT).map { it.toImmutableList() }.forEach { items ->
|
||||
IconRow(
|
||||
presets = items,
|
||||
isSelected = { it == pendingIcon },
|
||||
onItemClick = {
|
||||
pendingIcon = it
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChangeIconDialog(pendingIcon: AppIconPreset, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AlertDialog(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_cancel))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
AppIcon(preset = pendingIcon, isSelected = false, onClick = {})
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_dialog_title, stringResource(id = pendingIcon.labelResId)),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_dialog_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering the one row of icons that the user may choose from.
|
||||
*/
|
||||
@Composable
|
||||
fun IconRow(presets: ImmutableList<AppIconPreset>, isSelected: (AppIconPreset) -> Boolean, onItemClick: (AppIconPreset) -> Unit, modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
presets.forEach { preset ->
|
||||
val currentlySelected = isSelected(preset)
|
||||
IconGridElement(
|
||||
preset = preset,
|
||||
isSelected = currentlySelected,
|
||||
onClickHandler = {
|
||||
if (!currentlySelected) {
|
||||
onItemClick(preset)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(vertical = 18.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering an individual icon inside that grid, including the black border of the selected icon.
|
||||
*/
|
||||
@Composable
|
||||
fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClickHandler: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val boxModifier = Modifier.size(64.dp)
|
||||
Box(
|
||||
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
|
||||
) {
|
||||
AppIcon(preset = preset, isSelected = isSelected, onClickHandler, modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = preset.labelResId),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable rendering the icon and optionally a border, to indicate selection.
|
||||
*/
|
||||
@Composable
|
||||
fun AppIcon(preset: AppIconPreset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val bitmapSize by animateFloatAsState(
|
||||
targetValue = if (isSelected) 48f else 64f,
|
||||
label = "Icon Size",
|
||||
animationSpec = tween(durationMillis = 150, easing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f))
|
||||
)
|
||||
val imageModifier = modifier
|
||||
.size(bitmapSize.dp)
|
||||
.graphicsLayer(
|
||||
shape = CircleShape,
|
||||
shadowElevation = if (isSelected) 4f else 8f,
|
||||
clip = true
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
Image(
|
||||
painterResource(id = preset.iconPreviewResId),
|
||||
contentDescription = stringResource(id = preset.labelResId),
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A clickable "learn more" block of text.
|
||||
*/
|
||||
@Composable
|
||||
fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val learnMoreString = stringResource(R.string.preferences__app_icon_learn_more)
|
||||
val completeString = stringResource(R.string.preferences__app_icon_warning_learn_more)
|
||||
val learnMoreStartIndex = completeString.indexOf(learnMoreString).coerceAtLeast(0)
|
||||
val learnMoreEndIndex = learnMoreStartIndex + learnMoreString.length
|
||||
val doesStringEndWithLearnMore = learnMoreEndIndex >= completeString.lastIndex
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
append(completeString.substring(0, learnMoreStartIndex))
|
||||
}
|
||||
pushStringAnnotation(
|
||||
tag = URL_TAG,
|
||||
annotation = LEARN_MORE_TAG
|
||||
)
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
append(learnMoreString)
|
||||
}
|
||||
pop()
|
||||
if (!doesStringEndWithLearnMore) {
|
||||
append(completeString.substring(learnMoreEndIndex, completeString.lastIndex))
|
||||
}
|
||||
}
|
||||
ClickableText(
|
||||
text = annotatedText,
|
||||
onClick = { onClick() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme")
|
||||
@Composable
|
||||
private fun MainScreenPreviewLight() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Dark Theme")
|
||||
@Composable
|
||||
private fun MainScreenPreviewDark() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class AppIconTutorialFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
TutorialScreen(Modifier.padding(contentPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TutorialScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(Alignment.Center)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val borderShape = RoundedCornerShape(12.dp)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__app_icon_warning),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(borderShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_icon_tutorial_apps_homescreen),
|
||||
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 328.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.preferences__app_icon_notification_warning),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(borderShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_icon_tutorial_notification),
|
||||
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 328.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun TutorialScreenPreview() {
|
||||
TutorialScreen()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AppIconTutorialFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
enum class AppIconPreset(private val componentName: String, @DrawableRes val iconPreviewResId: Int, @StringRes val labelResId: Int) {
|
||||
DEFAULT(".RoutingActivity", R.drawable.ic_app_icon_default_top_preview, R.string.app_name),
|
||||
WHITE(".RoutingActivityAltWhite", R.drawable.ic_app_icon_signal_white_top_preview, R.string.app_name),
|
||||
COLOR(".RoutingActivityAltColor", R.drawable.ic_app_icon_signal_color_top_preview, R.string.app_name),
|
||||
DARK(".RoutingActivityAltDark", R.drawable.ic_app_icon_signal_dark_top_preview, R.string.app_name),
|
||||
DARK_VARIANT(".RoutingActivityAltDarkVariant", R.drawable.ic_app_icon_signal_dark_variant_top_preview, R.string.app_name),
|
||||
CHAT(".RoutingActivityAltChat", R.drawable.ic_app_icon_chat_top_preview, R.string.app_name),
|
||||
BUBBLES(".RoutingActivityAltBubbles", R.drawable.ic_app_icon_bubbles_top_preview, R.string.app_name),
|
||||
YELLOW(".RoutingActivityAltYellow", R.drawable.ic_app_icon_yellow_top_preview, R.string.app_name),
|
||||
NEWS(".RoutingActivityAltNews", R.drawable.ic_app_icon_news_top_preview, R.string.app_icon_label_news),
|
||||
NOTES(".RoutingActivityAltNotes", R.drawable.ic_app_icon_notes_top_preview, R.string.app_icon_label_notes),
|
||||
WEATHER(".RoutingActivityAltWeather", R.drawable.ic_app_icon_weather_top_preview, R.string.app_icon_label_weather),
|
||||
WAVES(".RoutingActivityAltWaves", R.drawable.ic_app_icon_waves_top_preview, R.string.app_icon_label_waves);
|
||||
|
||||
fun getComponentName(context: Context): ComponentName {
|
||||
val applicationContext = context.applicationContext
|
||||
return ComponentName(applicationContext, "org.thoughtcrime.securesms" + componentName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class AppIconUtility(context: Context) {
|
||||
private val applicationContext: Context = context.applicationContext
|
||||
private val pm = applicationContext.packageManager
|
||||
|
||||
val currentAppIcon by lazy { readCurrentAppIconFromPackageManager() }
|
||||
|
||||
fun isCurrentlySelected(preset: AppIconPreset): Boolean {
|
||||
return preset == currentAppIcon
|
||||
}
|
||||
|
||||
fun currentAppIconComponentName(): ComponentName {
|
||||
return currentAppIcon.getComponentName(applicationContext)
|
||||
}
|
||||
|
||||
fun setNewAppIcon(desiredAppIcon: AppIconPreset) {
|
||||
Log.d(TAG, "Setting new app icon.")
|
||||
pm.setComponentEnabledSetting(desiredAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${desiredAppIcon.name} enabled.")
|
||||
val previousAppIcon = currentAppIcon
|
||||
pm.setComponentEnabledSetting(previousAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${previousAppIcon.name} disabled.")
|
||||
enumValues<AppIconPreset>().filterNot { it == desiredAppIcon || it == previousAppIcon }.forEach {
|
||||
pm.setComponentEnabledSetting(it.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
Log.d(TAG, "${it.name} disabled.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCurrentAppIconFromPackageManager(): AppIconPreset {
|
||||
val activeIcon = enumValues<AppIconPreset>().firstOrNull {
|
||||
val componentName = it.getComponentName(applicationContext)
|
||||
val componentEnabledSetting = pm.getComponentEnabledSetting(componentName)
|
||||
|
||||
Log.d(TAG, "Found $componentName with state of $componentEnabledSetting")
|
||||
if (it == AppIconPreset.DEFAULT && componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
|
||||
return it
|
||||
}
|
||||
|
||||
componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
|
||||
return if (activeIcon == null) {
|
||||
setNewAppIcon(AppIconPreset.DEFAULT)
|
||||
AppIconPreset.DEFAULT
|
||||
} else {
|
||||
activeIcon
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AppIconUtility"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ChooseNavigationBarStyleFragmentBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
/**
|
||||
* Allows the user to choose between a compact and full-sized navigation bar.
|
||||
@@ -75,11 +74,7 @@ class ChooseNavigationBarStyleFragment : DialogFragment(R.layout.choose_navigati
|
||||
companion object {
|
||||
@DrawableRes
|
||||
fun getImageResourceId(isCompact: Boolean): Int {
|
||||
return if (FeatureFlags.callsTab()) {
|
||||
ThreeButtons.getImageResource(isCompact)
|
||||
} else {
|
||||
TwoButtons.getImageResource(isCompact)
|
||||
}
|
||||
return ThreeButtons.getImageResource(isCompact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.PinHashing
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil
|
||||
|
||||
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
|
||||
}
|
||||
|
||||
override fun handleSuccessfulPinEntry(pin: String) {
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
|
||||
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false
|
||||
|
||||
pinButton.cancelSpinning()
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.HelpSettingsFragment__licenses),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_licenseFragment)
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.HelpSettingsFragment__terms_amp_privacy_policy),
|
||||
linkId = R.string.terms_and_privacy_policy_url
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.help
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class LicenseFragment : ComposeFragment() {
|
||||
private val TAG = Log.tag(LicenseFragment::class.java)
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val textState: State<String> = Single.fromCallable {
|
||||
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeAsState(initial = "")
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.HelpSettingsFragment__licenses),
|
||||
onNavigationClick = findNavController()::popBackStack,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) {
|
||||
LicenseScreen(licenseText = textState.value, modifier = Modifier.padding(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LicenseScreen(licenseText: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = licenseText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LicenseFragmentPreview() {
|
||||
LicenseScreen("Lorem ipsum")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -93,12 +94,12 @@ private fun DrawScope.drawQr(
|
||||
}
|
||||
|
||||
// Logo border
|
||||
val deadzonePaddingPercent = 0.02f
|
||||
val deadzonePaddingPercent = 0.03f
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = cellWidthPx * 0.7f),
|
||||
style = Stroke(width = 4.dp.toPx()),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
@@ -156,9 +157,7 @@ private fun Preview() {
|
||||
Surface {
|
||||
QrCode(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
modifier = Modifier.size(200.dp),
|
||||
deadzonePercent = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Result of taking data from the QR scanner and trying to resolve it to a recipient.
|
||||
*/
|
||||
sealed class QrScanResult {
|
||||
class Success(val recipient: Recipient) : QrScanResult()
|
||||
|
||||
class NotFound(val username: String) : QrScanResult()
|
||||
|
||||
object InvalidData : QrScanResult()
|
||||
|
||||
object NetworkError : QrScanResult()
|
||||
}
|
||||
@@ -1,25 +1,61 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
|
||||
val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
@@ -29,23 +65,121 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = { TopAppBarContent(state.activeTab) }
|
||||
) { contentPadding ->
|
||||
UsernameLinkShareScreen(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope,
|
||||
contentPadding = contentPadding,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
if (state.indeterminateProgress) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.activeTab == ActiveTab.Code,
|
||||
enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }),
|
||||
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth })
|
||||
) {
|
||||
UsernameLinkShareScreen(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.activeTab == ActiveTab.Scan,
|
||||
enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }),
|
||||
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth })
|
||||
) {
|
||||
UsernameQrScanScreen(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
disposables = disposables.disposables,
|
||||
qrScanResult = state.qrScanResult,
|
||||
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
|
||||
onQrResultHandled = { viewModel.onQrResultHandled() },
|
||||
modifier = Modifier.padding(contentPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.onResume()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarContent(activeTab: ActiveTab) {
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
|
||||
active = activeTab == ActiveTab.Code,
|
||||
onClick = { viewModel.onTabSelected(ActiveTab.Code) },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
|
||||
active = activeTab == ActiveTab.Scan,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
viewModel.onTabSelected(ActiveTab.Scan)
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val colors = if (active) {
|
||||
ButtonDefaults.filledTonalButtonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = SignalTheme.colors.colorSurface2,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Buttons.MediumTonal(
|
||||
onClick = onClick,
|
||||
modifier = modifier.defaultMinSize(minWidth = 100.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = colors
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppBarPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
TopAppBarContent(activeTab = ActiveTab.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAll() {
|
||||
|
||||
@@ -7,8 +7,15 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
|
||||
* Represents the UI state of the [UsernameLinkSettingsFragment].
|
||||
*/
|
||||
data class UsernameLinkSettingsState(
|
||||
val activeTab: ActiveTab,
|
||||
val username: String,
|
||||
val usernameLink: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme
|
||||
)
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme,
|
||||
val qrScanResult: QrScanResult? = null,
|
||||
val indeterminateProgress: Boolean = false
|
||||
) {
|
||||
enum class ActiveTab {
|
||||
Code, Scan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,27 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import java.io.IOException
|
||||
|
||||
class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
|
||||
|
||||
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = username.value!!,
|
||||
usernameLink = UsernameUtil.generateLink(username.value!!),
|
||||
qrCodeData = null,
|
||||
@@ -54,6 +64,61 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun onTabSelected(tab: ActiveTab) {
|
||||
_state.value = _state.value.copy(
|
||||
activeTab = tab
|
||||
)
|
||||
}
|
||||
|
||||
fun onQrCodeScanned(url: String) {
|
||||
_state.value = _state.value.copy(
|
||||
indeterminateProgress = true
|
||||
)
|
||||
|
||||
disposable += Single
|
||||
.fromCallable {
|
||||
val username: String? = UsernameUtil.parseLink(url)
|
||||
|
||||
if (username == null) {
|
||||
Log.w(TAG, "Failed to parse username from url")
|
||||
return@fromCallable QrScanResult.InvalidData
|
||||
}
|
||||
|
||||
return@fromCallable try {
|
||||
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
|
||||
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
|
||||
QrScanResult.Success(Recipient.externalUsername(aci, username))
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Invalid username", e)
|
||||
QrScanResult.InvalidData
|
||||
} catch (e: NonSuccessfulResponseCodeException) {
|
||||
Log.w(TAG, "Non-successful response during username resolution", e)
|
||||
if (e.code == 404) {
|
||||
QrScanResult.NotFound(username)
|
||||
} else {
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during username resolution", e)
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result ->
|
||||
_state.value = _state.value.copy(
|
||||
qrScanResult = result,
|
||||
indeterminateProgress = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQrResultHandled() {
|
||||
_state.value = _state.value.copy(
|
||||
qrScanResult = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateQrCodeData(url: String): Single<QrCodeData> {
|
||||
return Single.fromCallable {
|
||||
QrCodeData.forData(url, 64)
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -36,6 +35,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -49,12 +49,10 @@ fun UsernameLinkShareScreen(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(contentPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
QrCodeBadge(
|
||||
@@ -85,7 +83,8 @@ fun UsernameLinkShareScreen(
|
||||
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp)
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -186,6 +185,7 @@ private fun ScreenPreviewDarkTheme() {
|
||||
private fun previewState(): UsernameLinkSettingsState {
|
||||
val link = UsernameUtil.generateLink("maya.45")
|
||||
return UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = "maya.45",
|
||||
usernameLink = link,
|
||||
qrCodeData = QrCodeData.forData(link, 64),
|
||||
|
||||
@@ -1,14 +1,144 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to start a chat.
|
||||
*/
|
||||
@Composable
|
||||
fun UsernameQrScanScreen(modifier: Modifier = Modifier) {
|
||||
// TODO
|
||||
Text(text = "QR Scanner Placeholder")
|
||||
fun UsernameQrScanScreen(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
disposables: CompositeDisposable,
|
||||
qrScanResult: QrScanResult?,
|
||||
onQrCodeScanned: (String) -> Unit,
|
||||
onQrResultHandled: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (qrScanResult) {
|
||||
QrScanResult.InvalidData -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled)
|
||||
}
|
||||
QrScanResult.NetworkError -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.NotFound -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.Success -> {
|
||||
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
|
||||
onQrResultHandled()
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val view = QrScannerView(context)
|
||||
disposables += view.qrData.subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
},
|
||||
update = { view ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawQrCrosshair()
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.UsernameLinkSettings_qr_scan_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(id = android.R.string.ok),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawQrCrosshair() {
|
||||
val crosshairWidth: Float = size.minDimension * 0.6f
|
||||
val clearWidth: Float = crosshairWidth * 0.75f
|
||||
|
||||
// Draw a full white rounded rect...
|
||||
drawRoundRect(
|
||||
color = Color.White,
|
||||
topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2),
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
size = Size(crosshairWidth, crosshairWidth),
|
||||
cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx())
|
||||
)
|
||||
|
||||
// ...then cut out the middle parts with BlendMode.Clear to leave us with just the corners
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(center.x - clearWidth / 2, 0f),
|
||||
style = Fill,
|
||||
size = Size(clearWidth, size.height),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(0f, center.y - clearWidth / 2),
|
||||
style = Fill,
|
||||
size = Size(size.width, clearWidth),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import android.graphics.Color
|
||||
import android.text.Annotation
|
||||
import android.text.Selection
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.MetricAffectingSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
|
||||
@@ -57,20 +56,18 @@ object SpoilerAnnotation {
|
||||
revealedSpoilers.clear()
|
||||
}
|
||||
|
||||
class SpoilerClickableSpan(private val spoiler: Annotation) : ClickableSpan() {
|
||||
class SpoilerClickableSpan(private val spoiler: Annotation) : MetricAffectingSpan() {
|
||||
val spoilerRevealed
|
||||
get() = revealedSpoilers.contains(spoiler.value)
|
||||
|
||||
override fun onClick(widget: View) {
|
||||
fun onClick(widget: View) {
|
||||
revealedSpoilers.add(spoiler.value)
|
||||
if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) {
|
||||
val text: Spannable = if (widget.text is Spannable) {
|
||||
widget.text as Spannable
|
||||
} else {
|
||||
SpannableString(widget.text)
|
||||
|
||||
if (widget is TextView) {
|
||||
val text = widget.text
|
||||
if (text is Spannable) {
|
||||
Selection.removeSelection(text)
|
||||
}
|
||||
Selection.removeSelection(text)
|
||||
widget.text = text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,5 +76,7 @@ object SpoilerAnnotation {
|
||||
ds.color = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateMeasureState(textPaint: TextPaint) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +1,113 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.text.Layout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil
|
||||
|
||||
/**
|
||||
* Handles drawing the spoiler sparkles for a TextView.
|
||||
*/
|
||||
abstract class SpoilerRenderer {
|
||||
class SpoilerRenderer(
|
||||
private val spoilerDrawable: SpoilerDrawable,
|
||||
private val renderForComposing: Boolean,
|
||||
@Px private val padding: Int,
|
||||
@ColorInt composeBackgroundColor: Int
|
||||
) {
|
||||
|
||||
abstract fun draw(
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
private val paint = Paint().apply { color = composeBackgroundColor }
|
||||
|
||||
fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
)
|
||||
|
||||
protected fun getLineTop(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
protected fun getLineBottom(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
protected inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
|
||||
return getOrPut(line * 31 + layout.hashCode() * 31, default)
|
||||
}
|
||||
|
||||
class SingleLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
) {
|
||||
) {
|
||||
if (startLine == endLine) {
|
||||
val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
val left = startOffset.coerceAtMost(endOffset)
|
||||
val right = startOffset.coerceAtLeast(endOffset)
|
||||
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
class MultiLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
) {
|
||||
val paragraphDirection = layout.getParagraphDirection(startLine)
|
||||
|
||||
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
|
||||
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
|
||||
|
||||
for (line in startLine + 1 until endLine) {
|
||||
val left: Int = layout.getLineLeft(line).toInt()
|
||||
val right: Int = layout.getLineRight(line).toInt()
|
||||
|
||||
lineTop = getLineTop(layout, line)
|
||||
lineBottom = getLineBottom(layout, line)
|
||||
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
|
||||
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
|
||||
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
|
||||
drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
|
||||
return
|
||||
}
|
||||
|
||||
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
|
||||
if (start > end) {
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
val paragraphDirection = layout.getParagraphDirection(startLine)
|
||||
|
||||
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
|
||||
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
drawPartialLine(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
|
||||
|
||||
for (line in startLine + 1 until endLine) {
|
||||
val left: Int = layout.getLineLeft(line).toInt()
|
||||
val right: Int = layout.getLineRight(line).toInt()
|
||||
|
||||
lineTop = getLineTop(layout, line)
|
||||
lineBottom = getLineBottom(layout, line)
|
||||
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
|
||||
if (start > end) {
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
|
||||
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
|
||||
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
|
||||
drawPartialLine(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
|
||||
}
|
||||
|
||||
private fun drawPartialLine(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(start, top, end, bottom)
|
||||
return
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
private fun getLineTop(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
private fun getLineBottom(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
private inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
|
||||
return getOrPut(line * 31 + layout.hashCode() * 31, default)
|
||||
}
|
||||
|
||||
private fun Canvas.drawComposeBackground(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
drawRoundRect(
|
||||
start.toFloat() - padding,
|
||||
top.toFloat() - padding,
|
||||
end.toFloat() + padding,
|
||||
bottom.toFloat(),
|
||||
padding.toFloat(),
|
||||
padding.toFloat(),
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,17 @@ import android.view.View
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
|
||||
|
||||
/**
|
||||
* Performs initial calculation on how to render spoilers and then delegates to the single line or
|
||||
* multi-line version of actually drawing the spoiler sparkles.
|
||||
* Performs initial calculation on how to render spoilers and then delegates to actually drawing the spoiler sparkles.
|
||||
*/
|
||||
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
|
||||
|
||||
private val single: SpoilerRenderer
|
||||
private val multi: SpoilerRenderer
|
||||
private val renderer: SpoilerRenderer
|
||||
private val spoilerDrawable: SpoilerDrawable
|
||||
private var animatorRunning = false
|
||||
private var textColor: Int
|
||||
@@ -42,8 +41,12 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
||||
init {
|
||||
textColor = view.textColors.defaultColor
|
||||
spoilerDrawable = SpoilerDrawable(textColor)
|
||||
single = SingleLineSpoilerRenderer(spoilerDrawable)
|
||||
multi = MultiLineSpoilerRenderer(spoilerDrawable)
|
||||
renderer = SpoilerRenderer(
|
||||
spoilerDrawable = spoilerDrawable,
|
||||
renderForComposing = renderForComposing,
|
||||
padding = 2.dp,
|
||||
composeBackgroundColor = ContextCompat.getColor(view.context, R.color.signal_colorOnSurfaceVariant1)
|
||||
)
|
||||
|
||||
view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
|
||||
override fun onViewDetachedFromWindow(v: View) = stopAnimating()
|
||||
@@ -85,8 +88,6 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
||||
)
|
||||
}
|
||||
|
||||
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
|
||||
|
||||
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset)
|
||||
hasSpoilersToRender = true
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
|
||||
if (ongoing && !isForegroundService) {
|
||||
try {
|
||||
ForegroundServiceUtil.startWhenCapable(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
|
||||
ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
|
||||
startForeground(notificationId, notification);
|
||||
isForegroundService = true;
|
||||
} catch (UnableToStartException e) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.annotation.CheckResult
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
|
||||
@@ -31,7 +33,11 @@ class ContactsManagementRepository(context: Context) {
|
||||
error("Cannot hide groups, self, or distribution lists.")
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.markHidden(recipient.id)
|
||||
val rotateProfileKey = !recipient.hasGroupsInCommon()
|
||||
SignalDatabase.recipients.markHidden(recipient.id, rotateProfileKey)
|
||||
if (rotateProfileKey) {
|
||||
ApplicationDependencies.getJobManager().add(RotateProfileKeyJob())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class ContactSearchPagedDataSource(
|
||||
return searchSize
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
|
||||
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
|
||||
val sections: List<ContactSearchConfiguration.Section> = if (displayEmptyState) {
|
||||
contactConfiguration.emptyStateSections
|
||||
} else {
|
||||
|
||||
@@ -37,7 +37,6 @@ import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -99,7 +98,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<ConversationMessage> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
|
||||
public @NonNull List<ConversationMessage> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
|
||||
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId);
|
||||
List<MessageRecord> records = new ArrayList<>(length);
|
||||
MentionHelper mentionHelper = new MentionHelper();
|
||||
@@ -128,11 +127,11 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
}
|
||||
}
|
||||
|
||||
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= size())) {
|
||||
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) {
|
||||
records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup()));
|
||||
}
|
||||
|
||||
if (messageRequestData.isHidden() && (start + length >= size())) {
|
||||
if (messageRequestData.isHidden() && (start + length >= totalSize)) {
|
||||
records.add(new InMemoryMessageRecord.RemovedContactHidden(threadId));
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.gifts.OpenableGift;
|
||||
@@ -89,7 +90,6 @@ import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
@@ -194,7 +194,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -320,7 +319,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
},
|
||||
() -> conversationViewModel.shouldPlayMessageAnimations() && list.getScrollState() == RecyclerView.SCROLL_STATE_IDLE,
|
||||
() -> list.canScrollVertically(1) || list.canScrollVertically(-1));
|
||||
() -> list.canScrollVertically(1) || list.canScrollVertically(-1),
|
||||
(viewHolder) -> {
|
||||
if (viewHolder instanceof ConversationAdapter.ConversationViewHolder) {
|
||||
ConversationAdapter.ConversationViewHolder conversationViewHolder = (ConversationAdapter.ConversationViewHolder) viewHolder;
|
||||
BindableConversationItem conversationItem = conversationViewHolder.getBindable();
|
||||
if (conversationItem != null) {
|
||||
return !MessageRecordUtil.isEditMessage(conversationItem.getConversationMessage().getMessageRecord());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
multiselectItemDecoration = new MultiselectItemDecoration(requireContext(), () -> chatWallpaper);
|
||||
|
||||
@@ -1191,7 +1200,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
public void stageOutgoingMessage(OutgoingMessage message) {
|
||||
if (message.getScheduledDate() != -1) {
|
||||
if (message.getScheduledDate() != -1 || message.isMessageEdit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1838,8 +1847,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
@Override
|
||||
public void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms) {
|
||||
if (getParentFragment() == null) return;
|
||||
final String REACTIONS_TAG = "REACTIONS";
|
||||
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), null);
|
||||
if (getParentFragmentManager().findFragmentByTag(REACTIONS_TAG) == null) {
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), REACTIONS_TAG);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2027,6 +2039,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
@Override
|
||||
public void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args) {
|
||||
if (listener.isInBubble()) {
|
||||
Intent intent = ConversationIntents.createBuilder(requireActivity(), recipient.getId(), threadId)
|
||||
.withStartingPosition(list.getChildAdapterPosition(parent))
|
||||
.build();
|
||||
|
||||
requireActivity().startActivity(intent);
|
||||
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args.skipSharedElementTransition(true)));
|
||||
return;
|
||||
}
|
||||
@@ -2045,26 +2062,23 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
requireActivity().setExitSharedElementCallback(new MaterialContainerTransformSharedElementCallback());
|
||||
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), sharedElement, MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME);
|
||||
|
||||
final Intent mediaPreviewIntent = MediaIntentFactory.create(requireActivity(), args);
|
||||
|
||||
if (listener.isInBubble()) {
|
||||
mediaPreviewIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
}
|
||||
|
||||
requireActivity().startActivity(mediaPreviewIntent, options.toBundle());
|
||||
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord.getId());
|
||||
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord);
|
||||
} else {
|
||||
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord.getId());
|
||||
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks) {
|
||||
GroupDescriptionDialog.show(getChildFragmentManager(), groupName, description, shouldLinkifyWebLinks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivatePaymentsClicked() {
|
||||
Intent intent = new Intent(requireContext(), PaymentsActivity.class);
|
||||
|
||||
@@ -411,6 +411,10 @@ public class ConversationIntents {
|
||||
return Objects.equals(this, BUBBLE);
|
||||
}
|
||||
|
||||
public boolean isInPopup() {
|
||||
return Objects.equals(this, POPUP);
|
||||
}
|
||||
|
||||
public boolean isNormal() {
|
||||
return Objects.equals(this, NORMAL);
|
||||
}
|
||||
@@ -437,7 +441,7 @@ public class ConversationIntents {
|
||||
}
|
||||
|
||||
private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) {
|
||||
if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) {
|
||||
if (threadId < 0 && SignalStore.internalValues().useConversationFragmentV2()) {
|
||||
Log.w(TAG, "Getting thread id from database...");
|
||||
// TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this.
|
||||
return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));
|
||||
|
||||
@@ -91,7 +91,6 @@ import org.thoughtcrime.securesms.components.ThumbnailView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
@@ -342,7 +341,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
bodyText.setOnLongClickListener(passthroughClickListener);
|
||||
bodyText.setOnClickListener(passthroughClickListener);
|
||||
bodyText.enableSpoilerFiltering();
|
||||
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
|
||||
}
|
||||
|
||||
@@ -488,6 +486,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
return SWIPE_RECT.contains((int) downX, (int) downY);
|
||||
}
|
||||
|
||||
public @Nullable ConversationItemBodyBubble getBodyBubble() {
|
||||
return bodyBubble;
|
||||
}
|
||||
|
||||
public @Nullable View getQuotedIndicator() {
|
||||
return quotedIndicator;
|
||||
}
|
||||
|
||||
public @Nullable ReactionsConversationView getReactionsView() {
|
||||
return reactionsView;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
@@ -1316,7 +1326,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
|
||||
|
||||
paymentViewStub.setVisibility(View.VISIBLE);
|
||||
paymentViewStub.get().bindPayment(messageRecord.getFromRecipient(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
|
||||
paymentViewStub.get().bindPayment(conversationRecipient.get(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -1514,6 +1524,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickHandler);
|
||||
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
messageBody.removeSpan(urlSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1933,10 +1944,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
|
||||
|
||||
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
|
||||
|
||||
@@ -7,7 +7,7 @@ enum class ConversationItemDisplayMode {
|
||||
/** Smaller bubbles, often trimming text and shrinking images. Used for quote threads. */
|
||||
CONDENSED,
|
||||
|
||||
/** Smaller bubbles, no footers */
|
||||
/** Smaller bubbles, always singular bubbles, with a footer. Used for edit message history. */
|
||||
EXTRA_CONDENSED,
|
||||
|
||||
/** Less length restrictions. Used to show more info in message details. */
|
||||
|
||||
@@ -2102,7 +2102,7 @@ public class ConversationParentFragment extends Fragment
|
||||
protected void initializeActionBar() {
|
||||
invalidateOptionsMenu();
|
||||
toolbar.setOnMenuItemClickListener(menuProvider::onMenuItemSelected);
|
||||
|
||||
toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button);
|
||||
if (isInBubble()) {
|
||||
toolbar.setNavigationIcon(DrawableUtil.tint(ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
|
||||
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)));
|
||||
@@ -2806,8 +2806,9 @@ public class ConversationParentFragment extends Fragment
|
||||
fragment.reload(recipient.get(), threadId);
|
||||
setVisibleThread(threadId);
|
||||
}
|
||||
|
||||
fragment.scrollToBottom();
|
||||
if (!inputPanel.inEditMessageMode()) {
|
||||
fragment.scrollToBottom();
|
||||
}
|
||||
attachmentManager.cleanup();
|
||||
|
||||
updateLinkPreviewState();
|
||||
@@ -3281,7 +3282,7 @@ public class ConversationParentFragment extends Fragment
|
||||
@Override
|
||||
public void onRecorderStarted() {
|
||||
final AudioManagerCompat audioManager = ApplicationDependencies.getAndroidCallAudioManager();
|
||||
if (audioManager.isBluetoothAvailable()) {
|
||||
if (audioManager.isBluetoothHeadsetAvailable()) {
|
||||
connectToBluetoothAndBeginRecording();
|
||||
} else {
|
||||
Log.d(TAG, "Recording from phone mic because no bluetooth devices were available.");
|
||||
@@ -3334,11 +3335,16 @@ public class ConversationParentFragment extends Fragment
|
||||
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
|
||||
voiceRecorderWakeLock.release();
|
||||
updateToggleButtonState();
|
||||
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
|
||||
vibrator.vibrate(20);
|
||||
|
||||
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
Activity activity = getActivity();
|
||||
if (activity != null) {
|
||||
Vibrator vibrator = ServiceUtil.getVibrator(activity);
|
||||
vibrator.vibrate(20);
|
||||
|
||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
|
||||
if (recordingSession != null) {
|
||||
recordingSession.completeRecording();
|
||||
}
|
||||
@@ -3346,13 +3352,18 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
@Override
|
||||
public void onRecorderCanceled(boolean byUser) {
|
||||
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
|
||||
voiceRecorderWakeLock.release();
|
||||
updateToggleButtonState();
|
||||
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
|
||||
vibrator.vibrate(50);
|
||||
|
||||
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
Activity activity = getActivity();
|
||||
if (activity != null) {
|
||||
Vibrator vibrator = ServiceUtil.getVibrator(activity);
|
||||
vibrator.vibrate(50);
|
||||
|
||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
|
||||
if (recordingSession != null) {
|
||||
if (byUser) {
|
||||
@@ -4275,24 +4286,10 @@ public class ConversationParentFragment extends Fragment
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext())
|
||||
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss());
|
||||
|
||||
if (recipient.isGroup() && recipient.isBlocked()) {
|
||||
builder.setTitle(R.string.ConversationActivity_delete_conversation);
|
||||
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
|
||||
builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
|
||||
} else if (recipient.isGroup()) {
|
||||
builder.setTitle(R.string.ConversationActivity_delete_and_leave_group);
|
||||
builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices);
|
||||
builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete());
|
||||
} else {
|
||||
builder.setTitle(R.string.ConversationActivity_delete_conversation);
|
||||
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
|
||||
builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
|
||||
}
|
||||
|
||||
builder.show();
|
||||
ConversationDialogs.displayDeleteDialog(requireContext(), recipient, () -> {
|
||||
requestModel.onDelete();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
|
||||
* respecting listeners and other positional information that can be set BEFORE we want to actually
|
||||
* resolve the view.
|
||||
*/
|
||||
final class ConversationReactionDelegate {
|
||||
public final class ConversationReactionDelegate {
|
||||
|
||||
private final Stub<ConversationReactionOverlay> overlayStub;
|
||||
private final PointF lastSeenDownPoint = new PointF();
|
||||
@@ -26,15 +26,15 @@ final class ConversationReactionDelegate {
|
||||
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
|
||||
private ConversationReactionOverlay.OnHideListener onHideListener;
|
||||
|
||||
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
|
||||
public ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
|
||||
this.overlayStub = overlayStub;
|
||||
}
|
||||
|
||||
boolean isShowing() {
|
||||
public boolean isShowing() {
|
||||
return overlayStub.resolved() && overlayStub.get().isShowing();
|
||||
}
|
||||
|
||||
void show(@NonNull Activity activity,
|
||||
public void show(@NonNull Activity activity,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@NonNull ConversationMessage conversationMessage,
|
||||
boolean isNonAdminInAnnouncementGroup,
|
||||
@@ -43,15 +43,15 @@ final class ConversationReactionDelegate {
|
||||
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
|
||||
}
|
||||
|
||||
void hide() {
|
||||
public void hide() {
|
||||
overlayStub.get().hide();
|
||||
}
|
||||
|
||||
void hideForReactWithAny() {
|
||||
public void hideForReactWithAny() {
|
||||
overlayStub.get().hideForReactWithAny();
|
||||
}
|
||||
|
||||
void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
|
||||
public void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
|
||||
this.onReactionSelectedListener = onReactionSelectedListener;
|
||||
|
||||
if (overlayStub.resolved()) {
|
||||
@@ -59,7 +59,7 @@ final class ConversationReactionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
|
||||
public void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener;
|
||||
|
||||
if (overlayStub.resolved()) {
|
||||
@@ -67,7 +67,7 @@ final class ConversationReactionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
|
||||
public void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
|
||||
this.onHideListener = onHideListener;
|
||||
|
||||
if (overlayStub.resolved()) {
|
||||
@@ -75,7 +75,7 @@ final class ConversationReactionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull MessageRecord getMessageRecord() {
|
||||
public @NonNull MessageRecord getMessageRecord() {
|
||||
if (!overlayStub.resolved()) {
|
||||
throw new IllegalStateException("Cannot call getMessageRecord right now.");
|
||||
}
|
||||
@@ -83,7 +83,7 @@ final class ConversationReactionDelegate {
|
||||
return overlayStub.get().getMessageRecord();
|
||||
}
|
||||
|
||||
boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
|
||||
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
|
||||
if (!overlayStub.resolved() || !overlayStub.get().isShowing()) {
|
||||
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
|
||||
lastSeenDownPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
|
||||
@@ -23,7 +23,7 @@ import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.constraintlayout.widget.Barrier;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -33,6 +33,7 @@ import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
@@ -408,6 +409,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
|
||||
private int getInputPanelHeight(@NonNull Activity activity) {
|
||||
if (SignalStore.internalValues().useConversationFragmentV2()) {
|
||||
Barrier conversationBottomPanelBarrier = activity.findViewById(R.id.conversation_bottom_panel_barrier);
|
||||
|
||||
return activity.getResources().getDisplayMetrics().heightPixels - conversationBottomPanelBarrier.getTop();
|
||||
}
|
||||
|
||||
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);
|
||||
View emojiDrawer = activity.findViewById(R.id.emoji_drawer);
|
||||
|
||||
@@ -428,7 +435,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = 21)
|
||||
private void updateSystemUiOnShow(@NonNull Activity activity) {
|
||||
Window window = activity.getWindow();
|
||||
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
final class MenuState {
|
||||
public final class MenuState {
|
||||
|
||||
private static final int MAX_FORWARDABLE_COUNT = 32;
|
||||
|
||||
@@ -41,50 +41,50 @@ final class MenuState {
|
||||
edit = builder.edit;
|
||||
}
|
||||
|
||||
boolean shouldShowForwardAction() {
|
||||
public boolean shouldShowForwardAction() {
|
||||
return forward;
|
||||
}
|
||||
|
||||
boolean shouldShowReplyAction() {
|
||||
public boolean shouldShowReplyAction() {
|
||||
return reply;
|
||||
}
|
||||
|
||||
boolean shouldShowDetailsAction() {
|
||||
public boolean shouldShowDetailsAction() {
|
||||
return details;
|
||||
}
|
||||
|
||||
boolean shouldShowSaveAttachmentAction() {
|
||||
public boolean shouldShowSaveAttachmentAction() {
|
||||
return saveAttachment;
|
||||
}
|
||||
|
||||
boolean shouldShowResendAction() {
|
||||
public boolean shouldShowResendAction() {
|
||||
return resend;
|
||||
}
|
||||
|
||||
boolean shouldShowCopyAction() {
|
||||
public boolean shouldShowCopyAction() {
|
||||
return copy;
|
||||
}
|
||||
|
||||
boolean shouldShowDeleteAction() {
|
||||
public boolean shouldShowDeleteAction() {
|
||||
return delete;
|
||||
}
|
||||
|
||||
boolean shouldShowReactions() {
|
||||
public boolean shouldShowReactions() {
|
||||
return reactions;
|
||||
}
|
||||
|
||||
boolean shouldShowPaymentDetails() {
|
||||
public boolean shouldShowPaymentDetails() {
|
||||
return paymentDetails;
|
||||
}
|
||||
|
||||
boolean shouldShowEditAction() {
|
||||
public boolean shouldShowEditAction() {
|
||||
return edit;
|
||||
}
|
||||
|
||||
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Set<MultiselectPart> selectedParts,
|
||||
boolean shouldShowMessageRequest,
|
||||
boolean isNonAdminInAnnouncementGroup)
|
||||
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Set<MultiselectPart> selectedParts,
|
||||
boolean shouldShowMessageRequest,
|
||||
boolean isNonAdminInAnnouncementGroup)
|
||||
{
|
||||
|
||||
Builder builder = new Builder();
|
||||
|
||||
@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
class ConversationItemAnimator(
|
||||
private val isInMultiSelectMode: () -> Boolean,
|
||||
private val shouldPlayMessageAnimations: () -> Boolean,
|
||||
private val isParentFilled: () -> Boolean
|
||||
private val isParentFilled: () -> Boolean,
|
||||
private val shouldUseSlideAnimation: (RecyclerView.ViewHolder) -> Boolean
|
||||
) : RecyclerView.ItemAnimator() {
|
||||
|
||||
private data class TweeningInfo(
|
||||
@@ -54,7 +55,7 @@ class ConversationItemAnimator(
|
||||
}
|
||||
|
||||
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
if (viewHolder.absoluteAdapterPosition > 1) {
|
||||
if (viewHolder.absoluteAdapterPosition > 1 || !shouldUseSlideAnimation(viewHolder)) {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
@@ -95,7 +96,7 @@ class ConversationItemAnimator(
|
||||
|
||||
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
return if (!isInMultiSelectMode() && shouldPlayMessageAnimations() && isParentFilled()) {
|
||||
if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder)) {
|
||||
if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder) || !shouldUseSlideAnimation(viewHolder)) {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
false
|
||||
} else {
|
||||
|
||||
@@ -80,7 +80,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
addItemDecoration(MessageQuoteHeaderDecoration(context))
|
||||
addItemDecoration(OriginalMessageSeparatorDecoration(context, R.string.MessageQuotesBottomSheet_replies))
|
||||
|
||||
doOnNextLayout {
|
||||
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.graphics.Rect
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.marginLeft
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -14,9 +15,9 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Serves as the separator between the original message and the messages that quote it in [MessageQuotesBottomSheet]
|
||||
* Serves as the separator between the original message and other messages. Used in [MessageQuotesBottomSheet] and [EditMessageHistoryDialog]
|
||||
*/
|
||||
class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
class OriginalMessageSeparatorDecoration(context: Context, val titleRes: Int) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val dividerMargin = ViewUtil.dpToPx(context, 32)
|
||||
private val dividerHeight = ViewUtil.dpToPx(context, 2)
|
||||
@@ -56,7 +57,9 @@ class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecorati
|
||||
return it
|
||||
}
|
||||
|
||||
val header: View = LayoutInflater.from(parent.context).inflate(R.layout.message_quote_header_decoration, parent, false)
|
||||
val header: View = LayoutInflater.from(parent.context).inflate(R.layout.original_message_separator_decoration, parent, false)
|
||||
val titleView: TextView = header.findViewById(R.id.separator_title)
|
||||
titleView.setText(titleRes)
|
||||
|
||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
|
||||
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
|
||||
@@ -10,6 +10,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.quotes.OriginalMessageSeparatorDecoration
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
|
||||
@@ -45,9 +47,9 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
override val peekHeightPercentage: Float = 0.4f
|
||||
|
||||
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
|
||||
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
|
||||
private val originalMessageId: Long by lazy { requireArguments().getLong(ARGUMENT_ORIGINAL_MESSAGE_ID) }
|
||||
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
|
||||
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
|
||||
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(originalMessageId, conversationRecipient) })
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
@@ -76,9 +78,10 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
binding.editHistoryList.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
addItemDecoration(OriginalMessageSeparatorDecoration(context, R.string.EditMessageHistoryDialog_title))
|
||||
}
|
||||
|
||||
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
|
||||
@@ -144,15 +147,15 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARGUMENT_MESSAGE_ID = "message_id"
|
||||
private const val ARGUMENT_ORIGINAL_MESSAGE_ID = "message_id"
|
||||
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
|
||||
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageRecord: MessageRecord) {
|
||||
EditMessageHistoryDialog()
|
||||
.apply {
|
||||
arguments = bundleOf(
|
||||
ARGUMENT_MESSAGE_ID to messageId,
|
||||
ARGUMENT_ORIGINAL_MESSAGE_ID to (messageRecord.originalMessageId?.id ?: messageRecord.id),
|
||||
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,16 +36,16 @@ object EditMessageHistoryRepository {
|
||||
.getMessageEditHistory(messageId)
|
||||
.toList()
|
||||
|
||||
if (records.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val attachmentHelper = AttachmentHelper()
|
||||
.apply {
|
||||
addAll(records)
|
||||
fetchAttachments()
|
||||
}
|
||||
|
||||
if (records.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(records[0].threadId))
|
||||
|
||||
return attachmentHelper
|
||||
|
||||
@@ -13,12 +13,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
/**
|
||||
* View model to show history of edits for a specific message.
|
||||
*/
|
||||
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
|
||||
class EditMessageHistoryViewModel(private val originalMessageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
|
||||
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
|
||||
|
||||
fun getEditHistory(): Observable<List<ConversationMessage>> {
|
||||
return EditMessageHistoryRepository
|
||||
.getEditHistory(messageId)
|
||||
.getEditHistory(originalMessageId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
@@ -15,6 +17,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
|
||||
private val theme = DynamicNoActionBarTheme()
|
||||
override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
|
||||
|
||||
private val motionEventRelay: MotionEventRelay by viewModels()
|
||||
|
||||
override fun onPreCreate() {
|
||||
theme.onCreate(this)
|
||||
}
|
||||
@@ -32,4 +36,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
|
||||
super.onNewIntent(intent)
|
||||
error("ON NEW INTENT")
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -15,6 +17,7 @@ import org.thoughtcrime.securesms.BindableConversationItem
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBannerView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable
|
||||
@@ -27,11 +30,17 @@ import org.thoughtcrime.securesms.conversation.v2.data.IncomingMedia
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ThreadHeader
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.HtmlUtil
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.ProjectionList
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
@@ -65,6 +74,8 @@ class ConversationAdapterV2(
|
||||
private val condensedMode: ConversationItemDisplayMode? = null
|
||||
|
||||
init {
|
||||
registerFactory(ThreadHeader::class.java, ::ThreadHeaderViewHolder, R.layout.conversation_item_banner)
|
||||
|
||||
registerFactory(ConversationUpdate::class.java) { parent ->
|
||||
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_update, parent, false)
|
||||
ConversationUpdateViewHolder(view)
|
||||
@@ -91,14 +102,14 @@ class ConversationAdapterV2(
|
||||
}
|
||||
}
|
||||
|
||||
fun getAdapterPositionForMessagePosition(startPosition: Int): Int {
|
||||
return startPosition - 1
|
||||
/** [messagePosition] is one-based index and adapter is zero-based. */
|
||||
fun getAdapterPositionForMessagePosition(messagePosition: Int): Int {
|
||||
return messagePosition - 1
|
||||
}
|
||||
|
||||
fun getLastVisibleConversationMessage(position: Int): ConversationMessage? {
|
||||
return try {
|
||||
// todo [cody] handle conversation banner adjustment
|
||||
getConversationMessage(position)
|
||||
getConversationMessage(position) ?: getConversationMessage(position - 1)
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
Log.w(TAG, "Race condition changed size of conversation", e)
|
||||
null
|
||||
@@ -131,6 +142,7 @@ class ConversationAdapterV2(
|
||||
override fun getConversationMessage(position: Int): ConversationMessage? {
|
||||
return when (val item = getItem(position)) {
|
||||
is ConversationMessageElement -> item.conversationMessage
|
||||
is ThreadHeader -> null
|
||||
null -> null
|
||||
else -> throw AssertionError("Invalid item: ${item.javaClass}")
|
||||
}
|
||||
@@ -166,6 +178,18 @@ class ConversationAdapterV2(
|
||||
// todo [cody] implement
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_selected.clear()
|
||||
}
|
||||
|
||||
fun toggleSelection(multiselectPart: MultiselectPart) {
|
||||
if (multiselectPart in _selected) {
|
||||
_selected.remove(multiselectPart)
|
||||
} else {
|
||||
_selected.add(multiselectPart)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConversationUpdateViewHolder(itemView: View) : ConversationViewHolder<ConversationUpdate>(itemView) {
|
||||
override fun bind(model: ConversationUpdate) {
|
||||
bindable.setEventListener(clickListener)
|
||||
@@ -294,6 +318,20 @@ class ConversationAdapterV2(
|
||||
protected val displayMode: ConversationItemDisplayMode
|
||||
get() = condensedMode ?: ConversationItemDisplayMode.STANDARD
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch())
|
||||
}
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
clickListener.onItemLongClick(
|
||||
it,
|
||||
bindable.getMultiselectPartForLatestTouch()
|
||||
)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun showProjectionArea() {
|
||||
bindable.showProjectionArea()
|
||||
}
|
||||
@@ -326,4 +364,75 @@ class ConversationAdapterV2(
|
||||
return bindable.getColorizerProjections(coordinateRoot)
|
||||
}
|
||||
}
|
||||
|
||||
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {
|
||||
private val conversationBanner: ConversationBannerView = itemView as ConversationBannerView
|
||||
|
||||
override fun bind(model: ThreadHeader) {
|
||||
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
|
||||
val isSelf = recipient.id == Recipient.self().id
|
||||
|
||||
conversationBanner.setAvatar(glideRequests, recipient)
|
||||
conversationBanner.showBackgroundBubble(recipient.hasWallpaper())
|
||||
val title: String = conversationBanner.setTitle(recipient)
|
||||
conversationBanner.setAbout(recipient)
|
||||
|
||||
if (recipient.isGroup) {
|
||||
if (groupInfo.pendingMemberCount > 0) {
|
||||
val invited = context.resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, groupInfo.pendingMemberCount, groupInfo.pendingMemberCount)
|
||||
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, groupInfo.fullMemberCount, groupInfo.fullMemberCount, invited))
|
||||
} else if (groupInfo.fullMemberCount > 0) {
|
||||
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members, groupInfo.fullMemberCount, groupInfo.fullMemberCount))
|
||||
} else {
|
||||
conversationBanner.setSubtitle(null)
|
||||
}
|
||||
} else if (isSelf) {
|
||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation))
|
||||
} else {
|
||||
val subtitle: String? = recipient.e164.map { e164: String? -> PhoneNumberFormatter.prettyPrint(e164!!) }.orElse(null)
|
||||
if (subtitle == null || subtitle == title) {
|
||||
conversationBanner.hideSubtitle()
|
||||
} else {
|
||||
conversationBanner.setSubtitle(subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedGroups.isEmpty() || isSelf) {
|
||||
if (TextUtils.isEmpty(groupInfo.description)) {
|
||||
conversationBanner.setLinkifyDescription(false)
|
||||
conversationBanner.hideDescription()
|
||||
} else {
|
||||
conversationBanner.setLinkifyDescription(true)
|
||||
val linkifyWebLinks = messageRequestState == MessageRequestState.NONE
|
||||
conversationBanner.showDescription()
|
||||
|
||||
GroupDescriptionUtil.setText(
|
||||
context,
|
||||
conversationBanner.description,
|
||||
groupInfo.description,
|
||||
linkifyWebLinks
|
||||
) {
|
||||
clickListener.onShowGroupDescriptionClicked(recipient.getDisplayName(context), groupInfo.description, linkifyWebLinks)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val description: String = when (sharedGroups.size) {
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(sharedGroups[0]))
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]))
|
||||
3 -> context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]), HtmlUtil.bold(sharedGroups[2]))
|
||||
else -> {
|
||||
val others: Int = sharedGroups.size - 2
|
||||
context.getString(
|
||||
R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
HtmlUtil.bold(sharedGroups[0]),
|
||||
HtmlUtil.bold(sharedGroups[1]),
|
||||
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
|
||||
)
|
||||
}
|
||||
}
|
||||
conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0))
|
||||
conversationBanner.showDescription()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,26 @@ object ConversationDialogs {
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun displayDeleteDialog(context: Context, recipient: Recipient, onDelete: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setNeutralButton(R.string.ConversationActivity_cancel, null)
|
||||
.apply {
|
||||
if (recipient.isGroup && recipient.isBlocked) {
|
||||
setTitle(R.string.ConversationActivity_delete_conversation)
|
||||
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
|
||||
setPositiveButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
|
||||
} else if (recipient.isGroup) {
|
||||
setTitle(R.string.ConversationActivity_delete_and_leave_group)
|
||||
setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices)
|
||||
setNegativeButton(R.string.ConversationActivity_delete_and_leave) { _, _ -> onDelete() }
|
||||
} else {
|
||||
setTitle(R.string.ConversationActivity_delete_conversation)
|
||||
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
|
||||
setNegativeButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Optional
|
||||
|
||||
class ConversationRecipientRepository(threadId: Long) {
|
||||
|
||||
@@ -21,4 +23,20 @@ class ConversationRecipientRepository(threadId: Long) {
|
||||
.refCount()
|
||||
.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
val groupRecord: Observable<Optional<GroupRecord>> by lazy {
|
||||
conversationRecipient
|
||||
.switchMapSingle {
|
||||
Single.fromCallable {
|
||||
if (it.isGroup) {
|
||||
SignalDatabase.groups.getGroup(it.id)
|
||||
} else {
|
||||
Optional.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.replay(1)
|
||||
.refCount()
|
||||
.observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.libsignal.protocol.InvalidMessageException
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
|
||||
@@ -13,11 +20,21 @@ import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ConversationDataSource
|
||||
import org.thoughtcrime.securesms.database.RxDatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientFormattingException
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ConversationRepository(context: Context) {
|
||||
|
||||
@@ -29,9 +46,12 @@ class ConversationRepository(context: Context) {
|
||||
*/
|
||||
fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single<ConversationThreadState> {
|
||||
return Single.fromCallable {
|
||||
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
|
||||
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!!
|
||||
|
||||
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
|
||||
val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition)
|
||||
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
|
||||
|
||||
val messageRequestData = metadata.messageRequestData
|
||||
val dataSource = ConversationDataSource(
|
||||
applicationContext,
|
||||
@@ -48,9 +68,7 @@ class ConversationRepository(context: Context) {
|
||||
ConversationThreadState(
|
||||
items = PagedData.createForObservable(dataSource, config),
|
||||
meta = metadata
|
||||
).apply {
|
||||
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
|
||||
}
|
||||
)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@@ -73,6 +91,58 @@ class ConversationRepository(context: Context) {
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
threadId: Long,
|
||||
threadRecipient: Recipient?,
|
||||
metricId: String?,
|
||||
body: String,
|
||||
slideDeck: SlideDeck?,
|
||||
scheduledDate: Long,
|
||||
messageToEdit: MessageId?,
|
||||
quote: QuoteModel?,
|
||||
mentions: List<Mention>,
|
||||
bodyRanges: BodyRangeList?
|
||||
): Completable {
|
||||
val sendCompletable = Completable.create { emitter ->
|
||||
if (body.isEmpty() && slideDeck?.containsMediaSlide() != true) {
|
||||
emitter.onError(InvalidMessageException("Message is empty!"))
|
||||
return@create
|
||||
}
|
||||
|
||||
if (threadRecipient == null) {
|
||||
emitter.onError(RecipientFormattingException("Badly formatted"))
|
||||
return@create
|
||||
}
|
||||
|
||||
val message = OutgoingMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
body = body,
|
||||
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
isUrgent = true,
|
||||
isSecure = true,
|
||||
bodyRanges = bodyRanges,
|
||||
scheduledDate = scheduledDate,
|
||||
outgoingQuote = quote,
|
||||
messageToEdit = messageToEdit?.id ?: 0,
|
||||
mentions = mentions
|
||||
)
|
||||
|
||||
MessageSender.send(
|
||||
ApplicationDependencies.getApplication(),
|
||||
message,
|
||||
threadId,
|
||||
MessageSender.SendType.SIGNAL,
|
||||
metricId
|
||||
) {
|
||||
emitter.onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
return sendCompletable
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun setLastVisibleMessageTimestamp(threadId: Long, lastVisibleMessageTimestamp: Long) {
|
||||
SignalExecutors.BOUNDED.submit { SignalDatabase.threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user