mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-14 14:03:18 +01:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff5c2aa16 | ||
|
|
829abf169a | ||
|
|
56e008ea4f | ||
|
|
2a16d8baed | ||
|
|
ee89629738 | ||
|
|
3e63ac46b4 | ||
|
|
c881c67f5e | ||
|
|
a3574292c6 | ||
|
|
94b308cecb | ||
|
|
d3e83b12d9 | ||
|
|
d77555266b | ||
|
|
3451ac4504 | ||
|
|
b51973f1d5 | ||
|
|
f568002e5c | ||
|
|
321cced323 | ||
|
|
4359336fd5 | ||
|
|
e8570c3680 | ||
|
|
fd1ff5e438 | ||
|
|
026d029614 | ||
|
|
ef058a1644 | ||
|
|
a35a167e7a | ||
|
|
36ef36be61 | ||
|
|
a874efee4e | ||
|
|
07c32e2a35 | ||
|
|
055f4b09ee | ||
|
|
737b1c962a | ||
|
|
99ac2cb333 | ||
|
|
a183057b32 | ||
|
|
2883c16560 | ||
|
|
d88534e71f | ||
|
|
a56e9e502e | ||
|
|
433e8266c9 | ||
|
|
490feb358c | ||
|
|
27e3c883c3 | ||
|
|
71e2b8225a | ||
|
|
6d4906dfa8 | ||
|
|
0156e74f5a | ||
|
|
c834cb6ff7 | ||
|
|
48360d08d4 | ||
|
|
74877b839e | ||
|
|
39e04bef17 | ||
|
|
c5af204de3 | ||
|
|
ca0dd03042 | ||
|
|
f8f70ed3e1 | ||
|
|
0510588a09 | ||
|
|
1d8fc4b7fd | ||
|
|
b7f5333b39 | ||
|
|
544121d035 | ||
|
|
4da4de3b99 | ||
|
|
0b62c0346b | ||
|
|
292956b18c | ||
|
|
925f347050 | ||
|
|
ea150939cb | ||
|
|
57f490e5db | ||
|
|
a141fdaf7d | ||
|
|
16f1fbf583 | ||
|
|
9d4e13cd08 | ||
|
|
73a7063867 | ||
|
|
cd5c253a78 | ||
|
|
372c6f6ba3 | ||
|
|
d6f4a89326 | ||
|
|
60905c7409 | ||
|
|
f3490d07bf | ||
|
|
b99855afbe | ||
|
|
921c903190 | ||
|
|
9771b53c79 | ||
|
|
0ab5bbb240 | ||
|
|
fef533f101 | ||
|
|
3399af5a96 | ||
|
|
022195508a | ||
|
|
d3daaff6a4 | ||
|
|
89a3c62637 | ||
|
|
6da9db6d86 | ||
|
|
c254b08e33 | ||
|
|
9d575650d1 | ||
|
|
d8ac5a390a | ||
|
|
60842a10ff | ||
|
|
1cab6f87a0 | ||
|
|
a0aeac767d | ||
|
|
99bd8e82ca | ||
|
|
bbdf54097e | ||
|
|
2a9576baf5 | ||
|
|
2ca4c2d1c1 | ||
|
|
3231f8302c | ||
|
|
e0be9b4ef5 | ||
|
|
83b0963533 | ||
|
|
f9548dcffe | ||
|
|
e94a84d4ec | ||
|
|
db5f8707ec | ||
|
|
0f15562a28 | ||
|
|
b300f223ba | ||
|
|
ad9337021c | ||
|
|
5e94c350ed | ||
|
|
666020c3dc | ||
|
|
f249a6edd5 | ||
|
|
2e45bd719a | ||
|
|
28f27915c5 | ||
|
|
08ebca501b | ||
|
|
417cda1d38 | ||
|
|
dd730f5fbf | ||
|
|
77bb3702a9 | ||
|
|
5046f58c6f | ||
|
|
d02f605874 | ||
|
|
36a8c4d8ba | ||
|
|
25f0427585 | ||
|
|
5a501f4815 | ||
|
|
de0a37d356 | ||
|
|
5c65d5435c | ||
|
|
8d6a4c2888 | ||
|
|
b4a7ffdc12 | ||
|
|
5dd10f6fcc | ||
|
|
e76b5007e0 | ||
|
|
16e8f9633e | ||
|
|
cb4a45fea3 | ||
|
|
0017b7af26 | ||
|
|
5f645193e4 | ||
|
|
607a06d379 | ||
|
|
149955e07a | ||
|
|
80b9e4e7ae | ||
|
|
f02ac86e45 | ||
|
|
45e96f0efe | ||
|
|
06894d6a7e | ||
|
|
b67dfe10d4 | ||
|
|
b9b6a57e2c | ||
|
|
ba2d005b2a | ||
|
|
f53679f24a | ||
|
|
7eb00e41a2 | ||
|
|
168e37c3fc | ||
|
|
98438ff8e4 | ||
|
|
d6a9ed1a8d | ||
|
|
b194c0e84b | ||
|
|
ed67e7ac04 | ||
|
|
43cd647036 |
@@ -46,8 +46,8 @@ ktlint {
|
||||
version = "0.47.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1235
|
||||
def canonicalVersionName = "6.15.3"
|
||||
def canonicalVersionCode = 1244
|
||||
def canonicalVersionName = "6.18.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -67,11 +67,13 @@ def selectableVariants = [
|
||||
'nightlyPnpRelease',
|
||||
'playProdDebug',
|
||||
'playProdSpinner',
|
||||
'playProdCanary',
|
||||
'playProdPerf',
|
||||
'playProdBenchmark',
|
||||
'playProdInstrumentation',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
'playStagingCanary',
|
||||
'playStagingSpinner',
|
||||
'playStagingPerf',
|
||||
'playStagingInstrumentation',
|
||||
@@ -318,6 +320,14 @@ android {
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Benchmark\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "true"
|
||||
}
|
||||
|
||||
canary {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Canary\""
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
@@ -476,6 +486,8 @@ dependencies {
|
||||
implementation libs.androidx.biometric
|
||||
implementation libs.androidx.sharetarget
|
||||
implementation libs.androidx.profileinstaller
|
||||
implementation libs.androidx.asynclayoutinflater
|
||||
implementation libs.androidx.asynclayoutinflater.appcompat
|
||||
|
||||
implementation (libs.firebase.messaging) {
|
||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||
@@ -554,9 +566,11 @@ dependencies {
|
||||
exclude group: 'org.freemarker'
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
implementation libs.kotlinx.collections.immutable
|
||||
|
||||
spinnerImplementation project(":spinner")
|
||||
spinnerImplementation libs.square.leakcanary
|
||||
|
||||
canaryImplementation libs.square.leakcanary
|
||||
|
||||
testImplementation testLibs.junit.junit
|
||||
testImplementation testLibs.assertj.core
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.ringrtc.CallId
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CallTableTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
CallTable.Direction.INCOMING,
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.setTimestamp(callId, conversationId, -1L)
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(-1L, call?.timestamp)
|
||||
|
||||
val messageRecord = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
|
||||
assertEquals(-1L, messageRecord.dateReceived)
|
||||
assertEquals(-1L, messageRecord.dateSent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
CallTable.Direction.INCOMING,
|
||||
now
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
SignalDatabase.calls.deleteGroupCall(call!!)
|
||||
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
|
||||
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
|
||||
assertNotEquals(0L, oldestDeletionTimestamp)
|
||||
assertNull(deletedCall!!.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
callId,
|
||||
harness.others[0],
|
||||
CallTable.Direction.OUTGOING,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
|
||||
assertEquals(CallTable.Event.DELETE, call?.event)
|
||||
assertNotEquals(oldestDeletionTimestamp, 0)
|
||||
assertNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
CallTable.Direction.OUTGOING,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
assertEquals(harness.self.id, call?.ringerRecipient)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId,
|
||||
harness.others[0],
|
||||
CallTable.Direction.INCOMING,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.JOINED, call?.event)
|
||||
assertNull(call?.ringerRecipient)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@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],
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
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],
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
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],
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
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],
|
||||
timestamp = System.currentTimeMillis(),
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
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],
|
||||
timestamp = System.currentTimeMillis(),
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPriorCallEventWithNewerTimestamp_whenIReceiveAGroupCallUpdateMessage_thenIExpectAnUpdatedTimestamp() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.getCallById(callId, conversationId).let {
|
||||
assertNotNull(it)
|
||||
assertEquals(now, it?.timestamp)
|
||||
}
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = harness.others[0],
|
||||
sender = harness.others[1],
|
||||
timestamp = 1L,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
assertEquals(1L, call?.timestamp)
|
||||
}
|
||||
|
||||
@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],
|
||||
direction = CallTable.Direction.INCOMING,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = harness.others[0],
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DELETE, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGenericCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAJoinedCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() {
|
||||
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],
|
||||
CallTable.Direction.INCOMING,
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGenericCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARingingCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyLocally_thenIMoveToAcceptedState() {
|
||||
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],
|
||||
CallTable.Direction.INCOMING,
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToAcceptedState() {
|
||||
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],
|
||||
CallTable.Direction.INCOMING,
|
||||
now
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyLocally_thenIMoveToMissedState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToMissedState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACallEvent_whenRingIsAcceptedOnAnotherDevice_thenIMoveToAcceptedState() {
|
||||
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],
|
||||
timestamp = now,
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
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,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
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,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
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,
|
||||
harness.others[0],
|
||||
CallTable.Direction.OUTGOING,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.CANCELLED_BY_RINGER
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(harness.others[1], call?.ringerRecipient)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_LOCALLY
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.ACCEPTED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
|
||||
val callId = 1L
|
||||
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
callId,
|
||||
harness.others[0],
|
||||
harness.others[1],
|
||||
System.currentTimeMillis(),
|
||||
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, conversationId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
assertNotNull(call?.messageId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.thoughtcrime.securesms.database.helpers.SignalDatabaseMigrations
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
/**
|
||||
* A test that guarantees that a freshly-created database looks the same as one that went through the upgrade path.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseConsistencyTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun test() {
|
||||
val currentVersionStatements = SignalDatabase.rawDatabase.getAllCreateStatements()
|
||||
val testHelper = InMemoryTestHelper(ApplicationDependencies.getApplication()).also {
|
||||
it.onUpgrade(it.writableDatabase, 181, SignalDatabaseMigrations.DATABASE_VERSION)
|
||||
}
|
||||
|
||||
val upgradedStatements = testHelper.readableDatabase.getAllCreateStatements()
|
||||
|
||||
if (currentVersionStatements != upgradedStatements) {
|
||||
var message = "\n"
|
||||
|
||||
val currentByName = currentVersionStatements.associateBy { it.name }
|
||||
val upgradedByName = upgradedStatements.associateBy { it.name }
|
||||
|
||||
if (currentByName.keys != upgradedByName.keys) {
|
||||
val exclusiveToCurrent = currentByName.keys - upgradedByName.keys
|
||||
val exclusiveToUpgrade = upgradedByName.keys - currentByName.keys
|
||||
|
||||
message += "SQL entities exclusive to the newly-created database: $exclusiveToCurrent\n"
|
||||
message += "SQL entities exclusive to the upgraded database: $exclusiveToUpgrade\n\n"
|
||||
} else {
|
||||
for (currentEntry in currentByName) {
|
||||
val upgradedValue: Statement = upgradedByName[currentEntry.key]!!
|
||||
if (upgradedValue.sql != currentEntry.value.sql) {
|
||||
message += "Statement differed:\n"
|
||||
message += "newly-created:\n"
|
||||
message += "${currentEntry.value.sql}\n\n"
|
||||
message += "upgraded:\n"
|
||||
message += "${upgradedValue.sql}\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(message, false)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Statement(
|
||||
val name: String,
|
||||
val sql: String
|
||||
)
|
||||
|
||||
private fun SQLiteDatabase.getAllCreateStatements(): List<Statement> {
|
||||
return this.rawQuery("SELECT name, sql FROM sqlite_schema WHERE sql NOT NULL AND name != 'sqlite_sequence'")
|
||||
.readToList { cursor ->
|
||||
Statement(
|
||||
name = cursor.requireNonNullString("name"),
|
||||
sql = cursor.requireNonNullString("sql").normalizeSql()
|
||||
)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
|
||||
private fun String.normalizeSql(): String {
|
||||
return this
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.joinToString(separator = " ")
|
||||
.replace(Regex.fromLiteral("( "), "(")
|
||||
.replace(Regex.fromLiteral(" )"), ")")
|
||||
.replace(Regex.fromLiteral("CREATE TABLE \"call\""), "CREATE TABLE call") // solves a specific weirdness with inconsequential quotes
|
||||
}
|
||||
|
||||
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
for (statement in SNAPSHOT_V181) {
|
||||
db.execSQL(statement.sql)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
SignalDatabaseMigrations.migrate(application, db, 181, SignalDatabaseMigrations.DATABASE_VERSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the list of statements that existed at version 181. Never change this.
|
||||
*/
|
||||
private val SNAPSHOT_V181 = listOf(
|
||||
Statement(
|
||||
name = "message",
|
||||
sql = "CREATE TABLE message (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n date_sent INTEGER NOT NULL,\n date_received INTEGER NOT NULL,\n date_server INTEGER DEFAULT -1,\n thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n recipient_device_id INTEGER,\n type INTEGER NOT NULL,\n body TEXT,\n read INTEGER DEFAULT 0,\n ct_l TEXT,\n exp INTEGER,\n m_type INTEGER,\n m_size INTEGER,\n st INTEGER,\n tr_id TEXT,\n subscription_id INTEGER DEFAULT -1, \n receipt_timestamp INTEGER DEFAULT -1, \n delivery_receipt_count INTEGER DEFAULT 0, \n read_receipt_count INTEGER DEFAULT 0, \n viewed_receipt_count INTEGER DEFAULT 0,\n mismatched_identities TEXT DEFAULT NULL,\n network_failures TEXT DEFAULT NULL,\n expires_in INTEGER DEFAULT 0,\n expire_started INTEGER DEFAULT 0,\n notified INTEGER DEFAULT 0,\n quote_id INTEGER DEFAULT 0,\n quote_author INTEGER DEFAULT 0,\n quote_body TEXT DEFAULT NULL,\n quote_missing INTEGER DEFAULT 0,\n quote_mentions BLOB DEFAULT NULL,\n quote_type INTEGER DEFAULT 0,\n shared_contacts TEXT DEFAULT NULL,\n unidentified INTEGER DEFAULT 0,\n link_previews TEXT DEFAULT NULL,\n view_once INTEGER DEFAULT 0,\n reactions_unread INTEGER DEFAULT 0,\n reactions_last_seen INTEGER DEFAULT -1,\n remote_deleted INTEGER DEFAULT 0,\n mentions_self INTEGER DEFAULT 0,\n notified_timestamp INTEGER DEFAULT 0,\n server_guid TEXT DEFAULT NULL,\n message_ranges BLOB DEFAULT NULL,\n story_type INTEGER DEFAULT 0,\n parent_story_id INTEGER DEFAULT 0,\n export_state BLOB DEFAULT NULL,\n exported INTEGER DEFAULT 0,\n scheduled_date INTEGER DEFAULT -1\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "part",
|
||||
sql = "CREATE TABLE part (_id INTEGER PRIMARY KEY, mid INTEGER, seq INTEGER DEFAULT 0, ct TEXT, name TEXT, chset INTEGER, cd TEXT, fn TEXT, cid TEXT, cl TEXT, ctt_s INTEGER, ctt_t TEXT, encrypted INTEGER, pending_push INTEGER, _data TEXT, data_size INTEGER, file_name TEXT, unique_id INTEGER NOT NULL, digest BLOB, fast_preflight_id TEXT, voice_note INTEGER DEFAULT 0, borderless INTEGER DEFAULT 0, video_gif INTEGER DEFAULT 0, data_random BLOB, quote INTEGER DEFAULT 0, width INTEGER DEFAULT 0, height INTEGER DEFAULT 0, caption TEXT DEFAULT NULL, sticker_pack_id TEXT DEFAULT NULL, sticker_pack_key DEFAULT NULL, sticker_id INTEGER DEFAULT -1, sticker_emoji STRING DEFAULT NULL, data_hash TEXT DEFAULT NULL, blur_hash TEXT DEFAULT NULL, transform_properties TEXT DEFAULT NULL, transfer_file TEXT DEFAULT NULL, display_order INTEGER DEFAULT 0, upload_timestamp INTEGER DEFAULT 0, cdn_number INTEGER DEFAULT 0)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread",
|
||||
sql = "CREATE TABLE thread (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n date INTEGER DEFAULT 0, \n meaningful_messages INTEGER DEFAULT 0,\n recipient_id INTEGER NOT NULL UNIQUE REFERENCES recipient (_id) ON DELETE CASCADE,\n read INTEGER DEFAULT 1, \n type INTEGER DEFAULT 0, \n error INTEGER DEFAULT 0, \n snippet TEXT, \n snippet_type INTEGER DEFAULT 0, \n snippet_uri TEXT DEFAULT NULL, \n snippet_content_type TEXT DEFAULT NULL, \n snippet_extras TEXT DEFAULT NULL, \n unread_count INTEGER DEFAULT 0, \n archived INTEGER DEFAULT 0, \n status INTEGER DEFAULT 0, \n delivery_receipt_count INTEGER DEFAULT 0, \n read_receipt_count INTEGER DEFAULT 0, \n expires_in INTEGER DEFAULT 0, \n last_seen INTEGER DEFAULT 0, \n has_sent INTEGER DEFAULT 0, \n last_scrolled INTEGER DEFAULT 0, \n pinned INTEGER DEFAULT 0, \n unread_self_mention_count INTEGER DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "identities",
|
||||
sql = "CREATE TABLE identities (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n address INTEGER UNIQUE, \n identity_key TEXT, \n first_use INTEGER DEFAULT 0, \n timestamp INTEGER DEFAULT 0, \n verified INTEGER DEFAULT 0, \n nonblocking_approval INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "drafts",
|
||||
sql = "CREATE TABLE drafts (\n _id INTEGER PRIMARY KEY, \n thread_id INTEGER, \n type TEXT, \n value TEXT\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "push",
|
||||
sql = "CREATE TABLE push (_id INTEGER PRIMARY KEY, type INTEGER, source TEXT, source_uuid TEXT, device_id INTEGER, body TEXT, content TEXT, timestamp INTEGER, server_timestamp INTEGER DEFAULT 0, server_delivered_timestamp INTEGER DEFAULT 0, server_guid TEXT DEFAULT NULL)"
|
||||
),
|
||||
Statement(
|
||||
name = "groups",
|
||||
sql = "CREATE TABLE groups (\n _id INTEGER PRIMARY KEY, \n group_id TEXT, \n recipient_id INTEGER,\n title TEXT,\n avatar_id INTEGER, \n avatar_key BLOB,\n avatar_content_type TEXT, \n avatar_relay TEXT,\n timestamp INTEGER,\n active INTEGER DEFAULT 1,\n avatar_digest BLOB, \n mms INTEGER DEFAULT 0, \n master_key BLOB, \n revision BLOB, \n decrypted_group BLOB, \n expected_v2_id TEXT DEFAULT NULL, \n former_v1_members TEXT DEFAULT NULL, \n distribution_id TEXT DEFAULT NULL, \n display_as_story INTEGER DEFAULT 0, \n auth_service_id TEXT DEFAULT NULL, \n last_force_update_timestamp INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "group_membership",
|
||||
sql = "CREATE TABLE group_membership ( _id INTEGER PRIMARY KEY, group_id TEXT NOT NULL, recipient_id INTEGER NOT NULL, UNIQUE(group_id, recipient_id) )"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient",
|
||||
sql = "CREATE TABLE recipient (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n uuid TEXT UNIQUE DEFAULT NULL,\n username TEXT UNIQUE DEFAULT NULL,\n phone TEXT UNIQUE DEFAULT NULL,\n email TEXT UNIQUE DEFAULT NULL,\n group_id TEXT UNIQUE DEFAULT NULL,\n group_type INTEGER DEFAULT 0,\n blocked INTEGER DEFAULT 0,\n message_ringtone TEXT DEFAULT NULL, \n message_vibrate INTEGER DEFAULT 0, \n call_ringtone TEXT DEFAULT NULL, \n call_vibrate INTEGER DEFAULT 0, \n notification_channel TEXT DEFAULT NULL, \n mute_until INTEGER DEFAULT 0, \n color TEXT DEFAULT NULL, \n seen_invite_reminder INTEGER DEFAULT 0,\n default_subscription_id INTEGER DEFAULT -1,\n message_expiration_time INTEGER DEFAULT 0,\n registered INTEGER DEFAULT 0,\n system_given_name TEXT DEFAULT NULL, \n system_family_name TEXT DEFAULT NULL, \n system_display_name TEXT DEFAULT NULL, \n system_photo_uri TEXT DEFAULT NULL, \n system_phone_label TEXT DEFAULT NULL, \n system_phone_type INTEGER DEFAULT -1, \n system_contact_uri TEXT DEFAULT NULL, \n system_info_pending INTEGER DEFAULT 0, \n profile_key TEXT DEFAULT NULL, \n profile_key_credential TEXT DEFAULT NULL, \n signal_profile_name TEXT DEFAULT NULL, \n profile_family_name TEXT DEFAULT NULL, \n profile_joined_name TEXT DEFAULT NULL, \n signal_profile_avatar TEXT DEFAULT NULL, \n profile_sharing INTEGER DEFAULT 0, \n last_profile_fetch INTEGER DEFAULT 0, \n unidentified_access_mode INTEGER DEFAULT 0, \n force_sms_selection INTEGER DEFAULT 0, \n storage_service_key TEXT UNIQUE DEFAULT NULL, \n mention_setting INTEGER DEFAULT 0, \n storage_proto TEXT DEFAULT NULL,\n capabilities INTEGER DEFAULT 0,\n last_session_reset BLOB DEFAULT NULL,\n wallpaper BLOB DEFAULT NULL,\n wallpaper_file TEXT DEFAULT NULL,\n about TEXT DEFAULT NULL,\n about_emoji TEXT DEFAULT NULL,\n extras BLOB DEFAULT NULL,\n groups_in_common INTEGER DEFAULT 0,\n chat_colors BLOB DEFAULT NULL,\n custom_chat_colors_id INTEGER DEFAULT 0,\n badges BLOB DEFAULT NULL,\n pni TEXT DEFAULT NULL,\n distribution_list_id INTEGER DEFAULT NULL,\n needs_pni_signature INTEGER DEFAULT 0,\n unregistered_timestamp INTEGER DEFAULT 0,\n hidden INTEGER DEFAULT 0,\n reporting_token BLOB DEFAULT NULL,\n system_nickname TEXT DEFAULT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_receipts",
|
||||
sql = "CREATE TABLE group_receipts (\n _id INTEGER PRIMARY KEY, \n mms_id INTEGER, \n address INTEGER, \n status INTEGER, \n timestamp INTEGER, \n unidentified INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "one_time_prekeys",
|
||||
sql = "CREATE TABLE one_time_prekeys (\n _id INTEGER PRIMARY KEY,\n account_id TEXT NOT NULL,\n key_id INTEGER UNIQUE, \n public_key TEXT NOT NULL, \n private_key TEXT NOT NULL,\n UNIQUE(account_id, key_id)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "signed_prekeys",
|
||||
sql = "CREATE TABLE signed_prekeys (\n _id INTEGER PRIMARY KEY,\n account_id TEXT NOT NULL,\n key_id INTEGER UNIQUE, \n public_key TEXT NOT NULL,\n private_key TEXT NOT NULL,\n signature TEXT NOT NULL, \n timestamp INTEGER DEFAULT 0,\n UNIQUE(account_id, key_id)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sessions",
|
||||
sql = "CREATE TABLE sessions (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n account_id TEXT NOT NULL,\n address TEXT NOT NULL,\n device INTEGER NOT NULL,\n record BLOB NOT NULL,\n UNIQUE(account_id, address, device)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sender_keys",
|
||||
sql = "CREATE TABLE sender_keys (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n address TEXT NOT NULL, \n device INTEGER NOT NULL, \n distribution_id TEXT NOT NULL,\n record BLOB NOT NULL, \n created_at INTEGER NOT NULL, \n UNIQUE(address,device, distribution_id) ON CONFLICT REPLACE\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sender_key_shared",
|
||||
sql = "CREATE TABLE sender_key_shared (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n distribution_id TEXT NOT NULL, \n address TEXT NOT NULL, \n device INTEGER NOT NULL, \n timestamp INTEGER DEFAULT 0, \n UNIQUE(distribution_id,address, device) ON CONFLICT REPLACE\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_retry_receipts",
|
||||
sql = "CREATE TABLE pending_retry_receipts(_id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, device INTEGER NOT NULL, sent_timestamp INTEGER NOT NULL, received_timestamp TEXT NOT NULL, thread_id INTEGER NOT NULL, UNIQUE(author,sent_timestamp) ON CONFLICT REPLACE)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker",
|
||||
sql = "CREATE TABLE sticker (_id INTEGER PRIMARY KEY AUTOINCREMENT, pack_id TEXT NOT NULL, pack_key TEXT NOT NULL, pack_title TEXT NOT NULL, pack_author TEXT NOT NULL, sticker_id INTEGER, cover INTEGER, pack_order INTEGER, emoji TEXT NOT NULL, content_type TEXT DEFAULT NULL, last_used INTEGER, installed INTEGER,file_path TEXT NOT NULL, file_length INTEGER, file_random BLOB, UNIQUE(pack_id, sticker_id, cover) ON CONFLICT IGNORE)"
|
||||
),
|
||||
Statement(
|
||||
name = "storage_key",
|
||||
sql = "CREATE TABLE storage_key (_id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER, key TEXT UNIQUE)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention",
|
||||
sql = "CREATE TABLE mention(_id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER, message_id INTEGER, recipient_id INTEGER, range_start INTEGER, range_length INTEGER)"
|
||||
),
|
||||
Statement(
|
||||
name = "payments",
|
||||
sql = "CREATE TABLE payments(_id INTEGER PRIMARY KEY, uuid TEXT DEFAULT NULL, recipient INTEGER DEFAULT 0, recipient_address TEXT DEFAULT NULL, timestamp INTEGER, note TEXT DEFAULT NULL, direction INTEGER, state INTEGER, failure_reason INTEGER, amount BLOB NOT NULL, fee BLOB NOT NULL, transaction_record BLOB DEFAULT NULL, receipt BLOB DEFAULT NULL, payment_metadata BLOB DEFAULT NULL, receipt_public_key TEXT DEFAULT NULL, block_index INTEGER DEFAULT 0, block_timestamp INTEGER DEFAULT 0, seen INTEGER, UNIQUE(uuid) ON CONFLICT ABORT)"
|
||||
),
|
||||
Statement(
|
||||
name = "chat_colors",
|
||||
sql = "CREATE TABLE chat_colors (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n chat_colors BLOB\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "emoji_search",
|
||||
sql = "CREATE TABLE emoji_search (\n _id INTEGER PRIMARY KEY,\n label TEXT NOT NULL,\n emoji TEXT NOT NULL,\n rank INTEGER DEFAULT 2147483647 \n )"
|
||||
),
|
||||
Statement(
|
||||
name = "avatar_picker",
|
||||
sql = "CREATE TABLE avatar_picker (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n last_used INTEGER DEFAULT 0,\n group_id TEXT DEFAULT NULL,\n avatar BLOB NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_call_ring",
|
||||
sql = "CREATE TABLE group_call_ring (\n _id INTEGER PRIMARY KEY,\n ring_id INTEGER UNIQUE,\n date_received INTEGER,\n ring_state INTEGER\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "reaction",
|
||||
sql = "CREATE TABLE reaction (\n _id INTEGER PRIMARY KEY,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n emoji TEXT NOT NULL,\n date_sent INTEGER NOT NULL,\n date_received INTEGER NOT NULL,\n UNIQUE(message_id, author_id) ON CONFLICT REPLACE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt",
|
||||
sql = "CREATE TABLE donation_receipt (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n receipt_type TEXT NOT NULL,\n receipt_date INTEGER NOT NULL,\n amount TEXT NOT NULL,\n currency TEXT NOT NULL,\n subscription_level INTEGER NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends",
|
||||
sql = "CREATE TABLE story_sends (\n _id INTEGER PRIMARY KEY,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n sent_timestamp INTEGER NOT NULL,\n allows_replies INTEGER NOT NULL,\n distribution_id TEXT NOT NULL REFERENCES distribution_list (distribution_id) ON DELETE CASCADE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "cds",
|
||||
sql = "CREATE TABLE cds (\n _id INTEGER PRIMARY KEY,\n e164 TEXT NOT NULL UNIQUE ON CONFLICT IGNORE,\n last_seen_at INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "remote_megaphone",
|
||||
sql = "CREATE TABLE remote_megaphone (\n _id INTEGER PRIMARY KEY,\n uuid TEXT UNIQUE NOT NULL,\n priority INTEGER NOT NULL,\n countries TEXT,\n minimum_version INTEGER NOT NULL,\n dont_show_before INTEGER NOT NULL,\n dont_show_after INTEGER NOT NULL,\n show_for_days INTEGER NOT NULL,\n conditional_id TEXT,\n primary_action_id TEXT,\n secondary_action_id TEXT,\n image_url TEXT,\n image_uri TEXT DEFAULT NULL,\n title TEXT NOT NULL,\n body TEXT NOT NULL,\n primary_action_text TEXT,\n secondary_action_text TEXT,\n shown_at INTEGER DEFAULT 0,\n finished_at INTEGER DEFAULT 0,\n primary_action_data TEXT DEFAULT NULL,\n secondary_action_data TEXT DEFAULT NULL,\n snoozed_at INTEGER DEFAULT 0,\n seen_count INTEGER DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_pni_signature_message",
|
||||
sql = "CREATE TABLE pending_pni_signature_message (\n _id INTEGER PRIMARY KEY,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n sent_timestamp INTEGER NOT NULL,\n device_id INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "call",
|
||||
sql = "CREATE TABLE call (\n _id INTEGER PRIMARY KEY,\n call_id INTEGER NOT NULL UNIQUE,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n peer INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n type INTEGER NOT NULL,\n direction INTEGER NOT NULL,\n event INTEGER NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_fts",
|
||||
sql = "CREATE VIRTUAL TABLE message_fts USING fts5(body, thread_id UNINDEXED, content=message, content_rowid=_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "remapped_recipients",
|
||||
sql = "CREATE TABLE remapped_recipients (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n old_id INTEGER UNIQUE, \n new_id INTEGER\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "remapped_threads",
|
||||
sql = "CREATE TABLE remapped_threads (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n old_id INTEGER UNIQUE, \n new_id INTEGER\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_payload",
|
||||
sql = "CREATE TABLE msl_payload (\n _id INTEGER PRIMARY KEY,\n date_sent INTEGER NOT NULL,\n content BLOB NOT NULL,\n content_hint INTEGER NOT NULL,\n urgent INTEGER NOT NULL DEFAULT 1\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient",
|
||||
sql = "CREATE TABLE msl_recipient (\n _id INTEGER PRIMARY KEY,\n payload_id INTEGER NOT NULL REFERENCES msl_payload (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL, \n device INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message",
|
||||
sql = "CREATE TABLE msl_message (\n _id INTEGER PRIMARY KEY,\n payload_id INTEGER NOT NULL REFERENCES msl_payload (_id) ON DELETE CASCADE,\n message_id INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile",
|
||||
sql = "CREATE TABLE notification_profile (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL UNIQUE,\n emoji TEXT NOT NULL,\n color TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n allow_all_calls INTEGER NOT NULL DEFAULT 0,\n allow_all_mentions INTEGER NOT NULL DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_schedule",
|
||||
sql = "CREATE TABLE notification_profile_schedule (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,\n enabled INTEGER NOT NULL DEFAULT 0,\n start INTEGER NOT NULL,\n end INTEGER NOT NULL,\n days_enabled TEXT NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_allowed_members",
|
||||
sql = "CREATE TABLE notification_profile_allowed_members (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL,\n UNIQUE(notification_profile_id, recipient_id) ON CONFLICT REPLACE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list",
|
||||
sql = "CREATE TABLE distribution_list (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT UNIQUE NOT NULL,\n distribution_id TEXT UNIQUE NOT NULL,\n recipient_id INTEGER UNIQUE REFERENCES recipient (_id),\n allows_replies INTEGER DEFAULT 1,\n deletion_timestamp INTEGER DEFAULT 0,\n is_unknown INTEGER DEFAULT 0,\n privacy_mode INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list_member",
|
||||
sql = "CREATE TABLE distribution_list_member (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n list_id INTEGER NOT NULL REFERENCES distribution_list (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id),\n privacy_mode INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_group_type_index",
|
||||
sql = "CREATE INDEX recipient_group_type_index ON recipient (group_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_pni_index",
|
||||
sql = "CREATE UNIQUE INDEX recipient_pni_index ON recipient (pni)"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_service_id_profile_key",
|
||||
sql = "CREATE INDEX recipient_service_id_profile_key ON recipient (uuid, profile_key) WHERE uuid NOT NULL AND profile_key NOT NULL"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_read_and_notified_and_thread_id_index",
|
||||
sql = "CREATE INDEX mms_read_and_notified_and_thread_id_index ON message (read, notified, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_type_index",
|
||||
sql = "CREATE INDEX mms_type_index ON message (type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_date_sent_index",
|
||||
sql = "CREATE INDEX mms_date_sent_index ON message (date_sent, recipient_id, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_date_server_index",
|
||||
sql = "CREATE INDEX mms_date_server_index ON message (date_server)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_thread_date_index",
|
||||
sql = "CREATE INDEX mms_thread_date_index ON message (thread_id, date_received)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_reactions_unread_index",
|
||||
sql = "CREATE INDEX mms_reactions_unread_index ON message (reactions_unread)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_story_type_index",
|
||||
sql = "CREATE INDEX mms_story_type_index ON message (story_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_parent_story_id_index",
|
||||
sql = "CREATE INDEX mms_parent_story_id_index ON message (parent_story_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_thread_story_parent_story_scheduled_date_index",
|
||||
sql = "CREATE INDEX mms_thread_story_parent_story_scheduled_date_index ON message (thread_id, date_received, story_type, parent_story_id, scheduled_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_quote_id_quote_author_scheduled_date_index",
|
||||
sql = "CREATE INDEX message_quote_id_quote_author_scheduled_date_index ON message (quote_id, quote_author, scheduled_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_exported_index",
|
||||
sql = "CREATE INDEX mms_exported_index ON message (exported)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_id_type_payment_transactions_index",
|
||||
sql = "CREATE INDEX mms_id_type_payment_transactions_index ON message (_id,type) WHERE type & 12884901888 != 0"
|
||||
),
|
||||
Statement(
|
||||
name = "part_mms_id_index",
|
||||
sql = "CREATE INDEX part_mms_id_index ON part (mid)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_push_index",
|
||||
sql = "CREATE INDEX pending_push_index ON part (pending_push)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_sticker_pack_id_index",
|
||||
sql = "CREATE INDEX part_sticker_pack_id_index ON part (sticker_pack_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_data_hash_index",
|
||||
sql = "CREATE INDEX part_data_hash_index ON part (data_hash)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_data_index",
|
||||
sql = "CREATE INDEX part_data_index ON part (_data)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_recipient_id_index",
|
||||
sql = "CREATE INDEX thread_recipient_id_index ON thread (recipient_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "archived_count_index",
|
||||
sql = "CREATE INDEX archived_count_index ON thread (archived, meaningful_messages)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_pinned_index",
|
||||
sql = "CREATE INDEX thread_pinned_index ON thread (pinned)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_read",
|
||||
sql = "CREATE INDEX thread_read ON thread (read)"
|
||||
),
|
||||
Statement(
|
||||
name = "draft_thread_index",
|
||||
sql = "CREATE INDEX draft_thread_index ON drafts (thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_id_index ON groups (group_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_recipient_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_recipient_id_index ON groups (recipient_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "expected_v2_id_index",
|
||||
sql = "CREATE UNIQUE INDEX expected_v2_id_index ON groups (expected_v2_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_distribution_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_distribution_id_index ON groups(distribution_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_receipt_mms_id_index",
|
||||
sql = "CREATE INDEX group_receipt_mms_id_index ON group_receipts (mms_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker_pack_id_index",
|
||||
sql = "CREATE INDEX sticker_pack_id_index ON sticker (pack_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker_sticker_id_index",
|
||||
sql = "CREATE INDEX sticker_sticker_id_index ON sticker (sticker_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "storage_key_type_index",
|
||||
sql = "CREATE INDEX storage_key_type_index ON storage_key (type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention_message_id_index",
|
||||
sql = "CREATE INDEX mention_message_id_index ON mention (message_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention_recipient_id_thread_id_index",
|
||||
sql = "CREATE INDEX mention_recipient_id_thread_id_index ON mention (recipient_id, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "timestamp_direction_index",
|
||||
sql = "CREATE INDEX timestamp_direction_index ON payments (timestamp, direction)"
|
||||
),
|
||||
Statement(
|
||||
name = "timestamp_index",
|
||||
sql = "CREATE INDEX timestamp_index ON payments (timestamp)"
|
||||
),
|
||||
Statement(
|
||||
name = "receipt_public_key_index",
|
||||
sql = "CREATE UNIQUE INDEX receipt_public_key_index ON payments (receipt_public_key)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_payload_date_sent_index",
|
||||
sql = "CREATE INDEX msl_payload_date_sent_index ON msl_payload (date_sent)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient_recipient_index",
|
||||
sql = "CREATE INDEX msl_recipient_recipient_index ON msl_recipient (recipient_id, device, payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient_payload_index",
|
||||
sql = "CREATE INDEX msl_recipient_payload_index ON msl_recipient (payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message_message_index",
|
||||
sql = "CREATE INDEX msl_message_message_index ON msl_message (message_id, payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "date_received_index",
|
||||
sql = "CREATE INDEX date_received_index on group_call_ring (date_received)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_schedule_profile_index",
|
||||
sql = "CREATE INDEX notification_profile_schedule_profile_index ON notification_profile_schedule (notification_profile_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_allowed_members_profile_index",
|
||||
sql = "CREATE INDEX notification_profile_allowed_members_profile_index ON notification_profile_allowed_members (notification_profile_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt_type_index",
|
||||
sql = "CREATE INDEX donation_receipt_type_index ON donation_receipt (receipt_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt_date_index",
|
||||
sql = "CREATE INDEX donation_receipt_date_index ON donation_receipt (receipt_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends_recipient_id_sent_timestamp_allows_replies_index",
|
||||
sql = "CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON story_sends (recipient_id, sent_timestamp, allows_replies)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends_message_id_distribution_id_index",
|
||||
sql = "CREATE INDEX story_sends_message_id_distribution_id_index ON story_sends (message_id, distribution_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list_member_list_id_recipient_id_privacy_mode_index",
|
||||
sql = "CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON distribution_list_member (list_id, recipient_id, privacy_mode)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_pni_recipient_sent_device_index",
|
||||
sql = "CREATE UNIQUE INDEX pending_pni_recipient_sent_device_index ON pending_pni_signature_message (recipient_id, sent_timestamp, device_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "call_call_id_index",
|
||||
sql = "CREATE INDEX call_call_id_index ON call (call_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "call_message_id_index",
|
||||
sql = "CREATE INDEX call_message_id_index ON call (message_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_ai",
|
||||
sql = "CREATE TRIGGER message_ai AFTER INSERT ON message BEGIN\n INSERT INTO message_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "message_ad",
|
||||
sql = "CREATE TRIGGER message_ad AFTER DELETE ON message BEGIN\n INSERT INTO message_fts(message_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "message_au",
|
||||
sql = "CREATE TRIGGER message_au AFTER UPDATE ON message BEGIN\n INSERT INTO message_fts(message_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n INSERT INTO message_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message_delete",
|
||||
sql = "CREATE TRIGGER msl_message_delete AFTER DELETE ON message \n BEGIN \n \tDELETE FROM msl_payload WHERE _id IN (SELECT payload_id FROM msl_message WHERE message_id = old._id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_attachment_delete",
|
||||
sql = "CREATE TRIGGER msl_attachment_delete AFTER DELETE ON part\n BEGIN\n \tDELETE FROM msl_payload WHERE _id IN (SELECT payload_id FROM msl_message WHERE message_id = old.mid);\n END"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.database.Cursor
|
||||
import android.util.Base64
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toSingleLine
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.MessageTypes.isOutgoingMessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Entry
|
||||
import org.thoughtcrime.securesms.testing.InMemoryLogger
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.serialize.SignalServiceAddressProtobufSerializer
|
||||
import org.whispersystems.signalservice.internal.serialize.SignalServiceMetadataProtobufSerializer
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
import java.util.Optional
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageContentProcessorTestV2 {
|
||||
|
||||
companion object {
|
||||
private val TAGS = listOf(MessageContentProcessor.TAG, MessageContentProcessorV2.TAG, AttachmentTable.TAG)
|
||||
|
||||
private val GENERALIZE_TAG = mapOf(
|
||||
MessageContentProcessor.TAG to "MCP",
|
||||
MessageContentProcessorV2.TAG to "MCP",
|
||||
AttachmentTable.TAG to AttachmentTable.TAG
|
||||
)
|
||||
|
||||
private val IGNORE_MESSAGE_COLUMNS = listOf(
|
||||
MessageTable.DATE_RECEIVED,
|
||||
MessageTable.NOTIFIED_TIMESTAMP,
|
||||
MessageTable.REACTIONS_LAST_SEEN,
|
||||
MessageTable.NOTIFIED
|
||||
)
|
||||
|
||||
private val IGNORE_ATTACHMENT_COLUMNS = listOf(
|
||||
AttachmentTable.UNIQUE_ID,
|
||||
AttachmentTable.TRANSFER_FILE
|
||||
)
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var processorV1: MessageContentProcessor
|
||||
private lateinit var processorV2: MessageContentProcessorV2
|
||||
private lateinit var testResult: TestResults
|
||||
private var envelopeTimestamp: Long = 0
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
processorV1 = MessageContentProcessor(harness.context)
|
||||
processorV2 = MessageContentProcessorV2(harness.context)
|
||||
envelopeTimestamp = System.currentTimeMillis()
|
||||
testResult = TestResults()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textMessage() {
|
||||
var start = envelopeTimestamp
|
||||
|
||||
val messages: List<TestMessage> = (0 until 100).map {
|
||||
start += 200
|
||||
TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(start),
|
||||
content = MessageContentFuzzer.fuzzTextMessage(),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.others[0], harness.self.id),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
|
||||
)
|
||||
}
|
||||
|
||||
testResult.runV2(messages)
|
||||
testResult.runV1(messages)
|
||||
|
||||
testResult.assert()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaMessage() {
|
||||
var start = envelopeTimestamp
|
||||
|
||||
val textMessages: List<TestMessage> = (0 until 10).map {
|
||||
start += 200
|
||||
TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(start),
|
||||
content = MessageContentFuzzer.fuzzTextMessage(),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.others[0], harness.self.id),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
|
||||
)
|
||||
}
|
||||
|
||||
val firstBatchMediaMessages: List<TestMessage> = (0 until 10).map {
|
||||
start += 200
|
||||
TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(start),
|
||||
content = MessageContentFuzzer.fuzzMediaMessageWithBody(textMessages),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.others[0], harness.self.id),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
|
||||
)
|
||||
}
|
||||
|
||||
val secondBatchNoContentMediaMessages: List<TestMessage> = (0 until 10).map {
|
||||
start += 200
|
||||
TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(start),
|
||||
content = MessageContentFuzzer.fuzzMediaMessageNoContent(textMessages + firstBatchMediaMessages),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.others[0], harness.self.id),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
|
||||
)
|
||||
}
|
||||
|
||||
val thirdBatchNoTextMediaMessagesMessages: List<TestMessage> = (0 until 10).map {
|
||||
start += 200
|
||||
TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(start),
|
||||
content = MessageContentFuzzer.fuzzMediaMessageNoText(textMessages + firstBatchMediaMessages),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.others[0], harness.self.id),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
|
||||
)
|
||||
}
|
||||
|
||||
testResult.runV2(textMessages + firstBatchMediaMessages + secondBatchNoContentMediaMessages + thirdBatchNoTextMediaMessagesMessages)
|
||||
testResult.runV1(textMessages + firstBatchMediaMessages + secondBatchNoContentMediaMessages + thirdBatchNoTextMediaMessagesMessages)
|
||||
|
||||
testResult.assert()
|
||||
}
|
||||
|
||||
private inner class TestResults {
|
||||
|
||||
private lateinit var v1Logs: List<Entry>
|
||||
private lateinit var v1Messages: List<List<Pair<String, String?>>>
|
||||
private lateinit var v1Attachments: List<List<Pair<String, String?>>>
|
||||
private lateinit var v2Logs: List<Entry>
|
||||
private lateinit var v2Messages: List<List<Pair<String, String?>>>
|
||||
private lateinit var v2Attachments: List<List<Pair<String, String?>>>
|
||||
|
||||
fun runV1(messages: List<TestMessage>) {
|
||||
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
|
||||
if (content.hasDataMessage()) {
|
||||
processorV1.process(
|
||||
MessageContentProcessor.MessageState.DECRYPTED_OK,
|
||||
toSignalServiceContent(envelope, content, metadata, serverDeliveredTimestamp),
|
||||
null,
|
||||
envelope.timestamp,
|
||||
-1
|
||||
)
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
v1Logs = harness.inMemoryLogger.logs()
|
||||
harness.inMemoryLogger.clear()
|
||||
|
||||
v1Messages = dumpMessages()
|
||||
v1Attachments = dumpAttachments()
|
||||
}
|
||||
|
||||
fun runV2(messages: List<TestMessage>) {
|
||||
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
|
||||
if (content.hasDataMessage()) {
|
||||
processorV2.process(
|
||||
envelope,
|
||||
content,
|
||||
metadata,
|
||||
serverDeliveredTimestamp,
|
||||
false
|
||||
)
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
v2Logs = harness.inMemoryLogger.logs()
|
||||
harness.inMemoryLogger.clear()
|
||||
|
||||
v2Messages = dumpMessages()
|
||||
v2Attachments = dumpAttachments()
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
SignalDatabase.rawDatabase.withinTransaction { db ->
|
||||
SignalDatabase.threads.deleteAllConversations()
|
||||
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${MessageTable.TABLE_NAME}'")
|
||||
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${ThreadTable.TABLE_NAME}'")
|
||||
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${AttachmentTable.TABLE_NAME}'")
|
||||
}
|
||||
}
|
||||
|
||||
fun assert() {
|
||||
v2Logs.zip(v1Logs)
|
||||
.forEach { (v2, v1) ->
|
||||
GENERALIZE_TAG[v2.tag]!!.assertIs(GENERALIZE_TAG[v1.tag]!!)
|
||||
|
||||
if (v2.tag != AttachmentTable.TAG) {
|
||||
if (v2.message?.startsWith("[") == true && v1.message?.startsWith("[") == false) {
|
||||
v2.message!!.substring(v2.message!!.indexOf(']') + 2).assertIs(v1.message)
|
||||
} else {
|
||||
v2.message.assertIs(v1.message)
|
||||
}
|
||||
} else {
|
||||
if (v2.message?.startsWith("Inserted attachment at ID: AttachmentId::") == true) {
|
||||
v2.message!!
|
||||
.substring(0, v2.message!!.indexOf(','))
|
||||
.assertIs(
|
||||
v1.message!!
|
||||
.substring(0, v1.message!!.indexOf(','))
|
||||
)
|
||||
} else {
|
||||
v2.message.assertIs(v1.message)
|
||||
}
|
||||
}
|
||||
v2.throwable.assertIs(v1.throwable)
|
||||
}
|
||||
|
||||
v2Messages.zip(v1Messages)
|
||||
.forEach { (v2, v1) ->
|
||||
v2.assertIs(v1)
|
||||
}
|
||||
|
||||
v2Attachments.zip(v1Attachments)
|
||||
.forEach { (v2, v1) ->
|
||||
v2.assertIs(v1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun InMemoryLogger.logs(): List<Entry> {
|
||||
return entries()
|
||||
.filter { TAGS.contains(it.tag) }
|
||||
}
|
||||
|
||||
private fun dumpMessages(): List<List<Pair<String, String?>>> {
|
||||
return dumpTable(MessageTable.TABLE_NAME)
|
||||
.map { row ->
|
||||
val newRow = row.toMutableList()
|
||||
newRow.removeIf { IGNORE_MESSAGE_COLUMNS.contains(it.first) }
|
||||
newRow
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpAttachments(): List<List<Pair<String, String?>>> {
|
||||
return dumpTable(AttachmentTable.TABLE_NAME)
|
||||
.map { row ->
|
||||
val newRow = row.toMutableList()
|
||||
newRow.removeIf { IGNORE_ATTACHMENT_COLUMNS.contains(it.first) }
|
||||
newRow
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpTable(table: String): List<List<Pair<String, String?>>> {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select()
|
||||
.from(table)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
|
||||
val index = cursor.getColumnIndex(column)
|
||||
var data: String? = when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
|
||||
column to data
|
||||
}
|
||||
map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSignalServiceContent(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long): SignalServiceContent {
|
||||
val localAddress = SignalServiceAddress(metadata.destinationServiceId, Optional.ofNullable(SignalStore.account().e164))
|
||||
val signalServiceMetadata = SignalServiceMetadata(
|
||||
SignalServiceAddress(metadata.sourceServiceId, Optional.ofNullable(metadata.sourceE164)),
|
||||
metadata.sourceDeviceId,
|
||||
envelope.timestamp,
|
||||
envelope.serverTimestamp,
|
||||
serverDeliveredTimestamp,
|
||||
metadata.sealedSender,
|
||||
envelope.serverGuid,
|
||||
Optional.ofNullable(metadata.groupId),
|
||||
metadata.destinationServiceId.toString()
|
||||
)
|
||||
|
||||
val contentProto = SignalServiceContentProto.newBuilder()
|
||||
.setLocalAddress(SignalServiceAddressProtobufSerializer.toProtobuf(localAddress))
|
||||
.setMetadata(SignalServiceMetadataProtobufSerializer.toProtobuf(signalServiceMetadata))
|
||||
.setContent(content)
|
||||
.build()
|
||||
|
||||
return SignalServiceContent.createFromProto(contentProto)!!
|
||||
}
|
||||
|
||||
fun typeColumnToString(type: Long): String {
|
||||
return """
|
||||
isOutgoingMessageType:${isOutgoingMessageType(type)}
|
||||
isForcedSms:${type and MessageTypes.MESSAGE_FORCE_SMS_BIT != 0L}
|
||||
isDraftMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_DRAFT_TYPE}
|
||||
isFailedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_FAILED_TYPE}
|
||||
isPendingMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_OUTBOX_TYPE || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENDING_TYPE}
|
||||
isSentType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_TYPE}
|
||||
isPendingSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
|
||||
isPendingSecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
|
||||
isPendingInsecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK}
|
||||
isInboxType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_INBOX_TYPE}
|
||||
isJoinedType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.JOINED_TYPE}
|
||||
isUnsupportedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.UNSUPPORTED_MESSAGE_TYPE}
|
||||
isInvalidMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.INVALID_MESSAGE_TYPE}
|
||||
isBadDecryptType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BAD_DECRYPT_TYPE}
|
||||
isSecureType:${type and MessageTypes.SECURE_MESSAGE_BIT != 0L}
|
||||
isPushType:${type and MessageTypes.PUSH_MESSAGE_BIT != 0L}
|
||||
isEndSessionType:${type and MessageTypes.END_SESSION_BIT != 0L}
|
||||
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
|
||||
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
|
||||
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
|
||||
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
|
||||
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
|
||||
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
|
||||
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
|
||||
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
|
||||
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
|
||||
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}
|
||||
isIncomingAudioCall:${type == MessageTypes.INCOMING_AUDIO_CALL_TYPE}
|
||||
isIncomingVideoCall:${type == MessageTypes.INCOMING_VIDEO_CALL_TYPE}
|
||||
isOutgoingAudioCall:${type == MessageTypes.OUTGOING_AUDIO_CALL_TYPE}
|
||||
isOutgoingVideoCall:${type == MessageTypes.OUTGOING_VIDEO_CALL_TYPE}
|
||||
isMissedAudioCall:${type == MessageTypes.MISSED_AUDIO_CALL_TYPE}
|
||||
isMissedVideoCall:${type == MessageTypes.MISSED_VIDEO_CALL_TYPE}
|
||||
isGroupCall:${type == MessageTypes.GROUP_CALL_TYPE}
|
||||
isGroupUpdate:${type and MessageTypes.GROUP_UPDATE_BIT != 0L}
|
||||
isGroupV2:${type and MessageTypes.GROUP_V2_BIT != 0L}
|
||||
isGroupQuit:${type and MessageTypes.GROUP_LEAVE_BIT != 0L && type and MessageTypes.GROUP_V2_BIT == 0L}
|
||||
isChatSessionRefresh:${type and MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT != 0L}
|
||||
isDuplicateMessageType:${type and MessageTypes.ENCRYPTION_REMOTE_DUPLICATE_BIT != 0L}
|
||||
isDecryptInProgressType:${type and 0x40000000 != 0L}
|
||||
isNoRemoteSessionType:${type and MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT != 0L}
|
||||
isLegacyType:${type and MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT != 0L || type and MessageTypes.ENCRYPTION_REMOTE_BIT != 0L}
|
||||
isProfileChange:${type == MessageTypes.PROFILE_CHANGE_TYPE}
|
||||
isGroupV1MigrationEvent:${type == MessageTypes.GV1_MIGRATION_TYPE}
|
||||
isChangeNumber:${type == MessageTypes.CHANGE_NUMBER_TYPE}
|
||||
isBoostRequest:${type == MessageTypes.BOOST_REQUEST_TYPE}
|
||||
isThreadMerge:${type == MessageTypes.THREAD_MERGE_TYPE}
|
||||
isSmsExport:${type == MessageTypes.SMS_EXPORT_TYPE}
|
||||
isGroupV2LeaveOnly:${type and MessageTypes.GROUP_V2_LEAVE_BITS == MessageTypes.GROUP_V2_LEAVE_BITS}
|
||||
isSpecialType:${type and MessageTypes.SPECIAL_TYPES_MASK != 0L}
|
||||
isStoryReaction:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_STORY_REACTION}
|
||||
isGiftBadge:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_GIFT_BADGE}
|
||||
isPaymentsNotificaiton:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION}
|
||||
isRequestToActivatePayments:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST}
|
||||
isPaymentsActivated:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED}
|
||||
""".trimIndent().replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").toSingleLine()
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -37,7 +37,7 @@ import android.util.Log as AndroidLog
|
||||
/**
|
||||
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
|
||||
*/
|
||||
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
|
||||
// @Ignore("Ignore test in normal testing as it's a performance test with no assertions")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageProcessingPerformanceTest {
|
||||
|
||||
@@ -58,14 +58,14 @@ class MessageProcessingPerformanceTest {
|
||||
mockkStatic(UnidentifiedAccessUtil::class)
|
||||
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkStatic(MessageContentProcessor::class)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
mockkObject(MessageContentProcessorV2)
|
||||
every { MessageContentProcessorV2.create(harness.application) } returns TimingMessageContentProcessorV2(harness.application)
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(UnidentifiedAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
unmockkStatic(MessageContentProcessorV2::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,7 +106,7 @@ class MessageProcessingPerformanceTest {
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(lastTimestamp))
|
||||
.awaitFor(1.minutes)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
@@ -125,7 +125,7 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessorV2.TAG }.drop(2)
|
||||
val iterator = takeLast.iterator()
|
||||
var processCount = 0L
|
||||
var processDuration = 0L
|
||||
@@ -141,7 +141,7 @@ class MessageProcessingPerformanceTest {
|
||||
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
|
||||
|
||||
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessorV2.endTag(lastTimestamp) }
|
||||
|
||||
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
|
||||
val messagePerSecond = messageCount.toFloat() / duration
|
||||
@@ -156,7 +156,7 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
|
||||
data class TestMessage(
|
||||
val envelope: SignalServiceProtos.Envelope,
|
||||
val content: SignalServiceProtos.Content,
|
||||
val metadata: EnvelopeMetadata,
|
||||
val serverDeliveredTimestamp: Long
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
}
|
||||
|
||||
private fun startTag(timestamp: Long) = "$timestamp start"
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(messageState: MessageState?, content: SignalServiceContent?, exceptionMetadata: ExceptionMetadata?, envelopeTimestamp: Long, smsMessageId: Long) {
|
||||
Log.d(TAG, startTag(envelopeTimestamp))
|
||||
super.process(messageState, content, exceptionMetadata, envelopeTimestamp, smsMessageId)
|
||||
Log.d(TAG, endTag(envelopeTimestamp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
|
||||
class TimingMessageContentProcessorV2(context: Context) : MessageContentProcessorV2(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessorV2::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
}
|
||||
|
||||
private fun startTag(timestamp: Long) = "$timestamp start"
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean) {
|
||||
Log.d(TAG, startTag(envelope.timestamp))
|
||||
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent)
|
||||
Log.d(TAG, endTag(envelope.timestamp))
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ class InMemoryLogger : Log.Logger() {
|
||||
latch.await()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
val latch = CountDownLatch(1)
|
||||
executor.execute {
|
||||
predicates.clear()
|
||||
logEntries.clear()
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await()
|
||||
}
|
||||
|
||||
private fun add(entry: Entry) {
|
||||
executor.execute {
|
||||
logEntries += entry
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.messages.TestMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Random but deterministic fuzzer for create various message content protos.
|
||||
*/
|
||||
object MessageContentFuzzer {
|
||||
|
||||
private val mediaTypes = listOf("image/png", "image/jpeg", "image/heic", "image/heif", "image/avif", "image/webp", "image/gif", "audio/aac", "audio/*", "video/mp4", "video/*", "text/x-vcard", "text/x-signal-plain", "application/x-signal-view-once", "*/*", "application/octet-stream")
|
||||
private val emojis = listOf("😂", "❤️", "🔥", "😍", "👀", "🤔", "🙏", "👍", "🤷", "🥺")
|
||||
|
||||
private val random = Random(1)
|
||||
|
||||
/**
|
||||
* Create an [Envelope].
|
||||
*/
|
||||
fun envelope(timestamp: Long): Envelope {
|
||||
return Envelope.newBuilder()
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(timestamp + 5)
|
||||
.setServerGuidBytes(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata to match an [Envelope].
|
||||
*/
|
||||
fun envelopeMetadata(source: RecipientId, destination: RecipientId): EnvelopeMetadata {
|
||||
return EnvelopeMetadata(
|
||||
sourceServiceId = Recipient.resolved(source).requireServiceId(),
|
||||
sourceE164 = null,
|
||||
sourceDeviceId = 1,
|
||||
sealedSender = true,
|
||||
groupId = null,
|
||||
destinationServiceId = Recipient.resolved(destination).requireServiceId()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random text message that will contain a body but may also contain
|
||||
* - An expire timer value
|
||||
* - Bold style body ranges
|
||||
*/
|
||||
fun fuzzTextMessage(): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
body = string()
|
||||
if (random.nextBoolean()) {
|
||||
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
|
||||
}
|
||||
if (random.nextBoolean()) {
|
||||
addBodyRanges(
|
||||
SignalServiceProtos.BodyRange.newBuilder().run {
|
||||
start = 0
|
||||
length = 1
|
||||
style = SignalServiceProtos.BodyRange.Style.BOLD
|
||||
build()
|
||||
}
|
||||
)
|
||||
}
|
||||
build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that may be:
|
||||
* - A text body
|
||||
* - A text body with a quote that references an existing message
|
||||
* - A text body with a quote that references a non existing message
|
||||
* - A message with 0-2 attachment pointers and may contain a text body
|
||||
*/
|
||||
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
if (random.nextBoolean()) {
|
||||
body = string()
|
||||
}
|
||||
|
||||
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().run {
|
||||
id = quoted.envelope.timestamp
|
||||
authorUuid = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
addAllAttachments(quoted.content.dataMessage.attachmentsList)
|
||||
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
|
||||
type = DataMessage.Quote.Type.NORMAL
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().run {
|
||||
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
|
||||
authorUuid = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
if (random.nextFloat() < 0.25) {
|
||||
val total = random.nextInt(1, 2)
|
||||
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
|
||||
}
|
||||
|
||||
build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a random media message that contains no traditional media content. It may be:
|
||||
* - A reaction to a prior message
|
||||
*/
|
||||
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
if (random.nextFloat() < 0.25) {
|
||||
val reactTo = previousMessages.random(random)
|
||||
reaction = DataMessage.Reaction.newBuilder().run {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorUuid = reactTo.metadata.sourceServiceId.toString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
build()
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that can never contain a text body. It may be:
|
||||
* - A sticker
|
||||
*/
|
||||
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().run {
|
||||
if (random.nextFloat() < 0.9) {
|
||||
sticker = DataMessage.Sticker.newBuilder().run {
|
||||
packId = byteString(length = 24)
|
||||
packKey = byteString(length = 128)
|
||||
stickerId = random.nextInt()
|
||||
data = attachmentPointer()
|
||||
emoji = emojis.random(random)
|
||||
build()
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random [String].
|
||||
*/
|
||||
fun string(length: Int = 10, allowNullString: Boolean = false): String {
|
||||
var string = ""
|
||||
|
||||
if (allowNullString && random.nextBoolean()) {
|
||||
return string
|
||||
}
|
||||
|
||||
for (i in 0 until length) {
|
||||
string += random.nextInt(65..90).toChar()
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random [ByteString].
|
||||
*/
|
||||
fun byteString(length: Int = 512): ByteString {
|
||||
return random.nextBytes(length).toProtoByteString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random [AttachmentPointer].
|
||||
*/
|
||||
fun attachmentPointer(): AttachmentPointer {
|
||||
return AttachmentPointer.newBuilder().run {
|
||||
cdnKey = string()
|
||||
contentType = mediaTypes.random(random)
|
||||
key = byteString()
|
||||
size = random.nextInt(1024 * 1024 * 50)
|
||||
thumbnail = byteString()
|
||||
digest = byteString()
|
||||
fileName = string()
|
||||
flags = 0
|
||||
width = random.nextInt(until = 1024)
|
||||
height = random.nextInt(until = 1024)
|
||||
caption = string(allowNullString = true)
|
||||
blurHash = string()
|
||||
uploadTimestamp = random.nextLong()
|
||||
cdnNumber = 1
|
||||
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server delivered timestamp that is always later than the envelope and server "received" timestamp.
|
||||
*/
|
||||
fun fuzzServerDeliveredTimestamp(envelopeTimestamp: Long): Long {
|
||||
return envelopeTimestamp + 10
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,8 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
SignalStore.settings().isMessageNotificationsEnabled = false
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ fun <T : Any?> T.assertIsNotNull() {
|
||||
assertThat(this, notNullValue())
|
||||
}
|
||||
|
||||
infix fun <T : Any> T.assertIs(expected: T) {
|
||||
infix fun <T : Any?> T.assertIs(expected: T) {
|
||||
assertThat(this, `is`(expected))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ object TestDbUtils {
|
||||
val database: SQLiteDatabase = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, MessageTable.ID_WHERE, buildArgs(messageId))
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
|
||||
}
|
||||
}
|
||||
|
||||
9
app/src/canary/AndroidManifest.xml
Normal file
9
app/src/canary/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".CanaryApplicationContext"
|
||||
tools:replace="android:name" />
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import leakcanary.LeakCanary
|
||||
import shark.AndroidReferenceMatchers
|
||||
|
||||
class CanaryApplicationContext : ApplicationContext() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder()
|
||||
.detectDiskReads()
|
||||
.detectDiskWrites()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
|
||||
try {
|
||||
Class.forName("dalvik.system.CloseGuard")
|
||||
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
|
||||
.invoke(null, true)
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
|
||||
LeakCanary.config = LeakCanary.config.copy(
|
||||
referenceMatchers = AndroidReferenceMatchers.appDefaults +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "android.service.media.MediaBrowserService\$ServiceBinder",
|
||||
fieldName = "this\$0"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "androidx.media.MediaBrowserServiceCompat\$MediaBrowserServiceImplApi26\$MediaBrowserServiceApi26",
|
||||
fieldName = "mBase"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "android.support.v4.media.MediaBrowserCompat",
|
||||
fieldName = "mImpl"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "android.support.v4.media.session.MediaControllerCompat",
|
||||
fieldName = "mToken"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "android.support.v4.media.session.MediaControllerCompat",
|
||||
fieldName = "mImpl"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService",
|
||||
fieldName = "mApplication"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "org.thoughtcrime.securesms.service.GenericForegroundService\$LocalBinder",
|
||||
fieldName = "this\$0"
|
||||
) +
|
||||
AndroidReferenceMatchers.ignoredInstanceField(
|
||||
className = "org.thoughtcrime.securesms.contacts.ContactsSyncAdapter",
|
||||
fieldName = "mContext"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"/>
|
||||
|
||||
<activity android:name=".conversation.mutiselect.forward.MultiselectForwardActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
@@ -514,6 +514,13 @@
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity
|
||||
android:name=".avatar.photo.PhotoEditorActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:label="@string/AndroidManifest__media_preview"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<activity android:name=".mediaoverview.MediaOverviewActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
|
||||
BIN
app/src/main/assets/fonts/Hatsuishi-Regular.otf
Normal file
BIN
app/src/main/assets/fonts/Hatsuishi-Regular.otf
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
||||
.addNonBlocking(this::ensureProfileUploaded)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
@@ -214,7 +215,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
|
||||
.addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings())
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.TargetApi;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MenuItem;
|
||||
@@ -19,7 +17,7 @@ import androidx.core.view.ViewCompat;
|
||||
import org.signal.qr.QrScannerView;
|
||||
import org.signal.qr.kitkat.ScanListener;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
@@ -52,7 +52,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -93,7 +93,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
|
||||
|
||||
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
|
||||
if (activityResult.getResultCode() == RESULT_OK) {
|
||||
if (activityResult.getResultCode() != RESULT_CANCELED) {
|
||||
handleManualRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,10 +31,9 @@ import android.os.Bundle;
|
||||
import android.util.Rational;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
@@ -126,6 +125,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
private PictureInPictureParams.Builder pipBuilderParams;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -157,6 +157,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
initializeResources();
|
||||
initializeViewModel(isLandscapeEnabled);
|
||||
initializePictureInPictureParams();
|
||||
|
||||
processIntent(getIntent());
|
||||
|
||||
@@ -276,14 +277,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
private boolean enterPipModeIfPossible() {
|
||||
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(9, 16))
|
||||
.build();
|
||||
enterPictureInPictureMode(params);
|
||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||
if (isSystemPipEnabledAndAvailable()) {
|
||||
if (viewModel.canEnterPipMode()) {
|
||||
enterPictureInPictureMode(pipBuilderParams.build());
|
||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
pipBuilderParams.setAutoEnterEnabled(false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -363,6 +366,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
});
|
||||
}
|
||||
|
||||
private void initializePictureInPictureParams() {
|
||||
if (isSystemPipEnabledAndAvailable()) {
|
||||
pipBuilderParams = new PictureInPictureParams.Builder();
|
||||
pipBuilderParams.setAspectRatio(new Rational(9, 16));
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
pipBuilderParams.setAutoEnterEnabled(true);
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
setPictureInPictureParams(pipBuilderParams.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
||||
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
|
||||
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
|
||||
@@ -416,15 +432,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
private void handleSetAudioHandset() {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.EARPIECE));
|
||||
}
|
||||
|
||||
private void handleSetAudioSpeaker() {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.SPEAKER_PHONE));
|
||||
}
|
||||
|
||||
private void handleSetAudioBluetooth() {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.BLUETOOTH));
|
||||
}
|
||||
|
||||
private void handleSetAudioWiredHeadset() {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.WIRED_HEADSET));
|
||||
}
|
||||
|
||||
private void handleSetMuteAudio(boolean enabled) {
|
||||
@@ -569,7 +589,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
|
||||
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
|
||||
new AlertDialog.Builder(this)
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.RedPhone_number_not_registered)
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
|
||||
@@ -786,17 +806,26 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
case HANDSET:
|
||||
handleSetAudioHandset();
|
||||
break;
|
||||
case HEADSET:
|
||||
case BLUETOOTH_HEADSET:
|
||||
handleSetAudioBluetooth();
|
||||
break;
|
||||
case SPEAKER:
|
||||
handleSetAudioSpeaker();
|
||||
break;
|
||||
case WIRED_HEADSET:
|
||||
handleSetAudioWiredHeadset();
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unknown output: " + audioOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
@Override
|
||||
public void onAudioOutputChanged31(@NonNull int audioDeviceInfo) {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoChanged(boolean isVideoEnabled) {
|
||||
handleSetMuteVideo(!isVideoEnabled);
|
||||
@@ -838,11 +867,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowParticipantsList() {
|
||||
CallParticipantsListDialog.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) {
|
||||
viewModel.setIsViewingFocusedParticipant(page);
|
||||
@@ -864,6 +888,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.RINGING_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCallInfoClicked() {
|
||||
CallParticipantsListDialog.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigateUpClicked() {
|
||||
onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
|
||||
|
||||
@@ -9,8 +9,11 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -145,4 +148,33 @@ public class PointerAttachment extends Attachment {
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment quotedAttachment) {
|
||||
SignalServiceAttachment thumbnail;
|
||||
try {
|
||||
thumbnail = quotedAttachment.hasThumbnail() ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.getThumbnail()) : null;
|
||||
} catch (InvalidMessageStructureException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(quotedAttachment.getContentType(),
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||
quotedAttachment.getFileName(),
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.avatar.photo
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
|
||||
class PhotoEditorActivity : FragmentWrapperActivity() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
super.attachBaseContext(newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT, this) { _, bundle ->
|
||||
setResult(Activity.RESULT_OK, Intent().putExtras(bundle))
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFragment(): Fragment = PhotoEditorFragment().apply {
|
||||
arguments = intent.extras
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<Avatar.Photo, Avatar.Photo?>() {
|
||||
override fun createIntent(context: Context, input: Avatar.Photo): Intent {
|
||||
return Intent(context, PhotoEditorActivity::class.java).apply {
|
||||
putExtras(PhotoEditorActivityArgs.Builder(AvatarBundler.bundlePhoto(input)).build().toBundle())
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Avatar.Photo? {
|
||||
val extras = intent?.extras
|
||||
if (resultCode != Activity.RESULT_OK || extras == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AvatarBundler.extractPhoto(extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.Navigation
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -18,7 +17,7 @@ import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||
val args = PhotoEditorActivityArgs.fromBundle(requireArguments())
|
||||
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
|
||||
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
|
||||
|
||||
@@ -34,7 +33,7 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
|
||||
}
|
||||
|
||||
override fun onDoneEditing() {
|
||||
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||
val args = PhotoEditorActivityArgs.fromBundle(requireArguments())
|
||||
val applicationContext = requireContext().applicationContext
|
||||
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
|
||||
|
||||
@@ -52,13 +51,12 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
|
||||
|
||||
ThreadUtil.runOnMain {
|
||||
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelEditing() {
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun restoreState() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
@@ -20,6 +21,7 @@ import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorActivity
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
|
||||
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
@@ -50,6 +52,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private lateinit var recycler: RecyclerView
|
||||
private lateinit var photoEditorLauncher: ActivityResultLauncher<Avatar.Photo>
|
||||
|
||||
private fun createFactory(): AvatarPickerViewModel.Factory {
|
||||
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
|
||||
@@ -138,8 +141,12 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
|
||||
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
|
||||
val photo = AvatarBundler.extractPhoto(bundle)
|
||||
viewModel.onAvatarEditCompleted(photo)
|
||||
}
|
||||
|
||||
photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo ->
|
||||
if (photo != null) {
|
||||
viewModel.onAvatarEditCompleted(photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +204,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
|
||||
private fun openPhotoEditor(photo: Avatar.Photo) {
|
||||
Navigation.findNavController(requireView())
|
||||
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
|
||||
photoEditorLauncher.launch(photo)
|
||||
}
|
||||
|
||||
private fun openVectorEditor(vector: Avatar.Vector) {
|
||||
|
||||
@@ -130,6 +130,7 @@ public class BackupDialog {
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting choose backup location dialog");
|
||||
fragment.startActivityForResult(intent, requestCode);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -36,7 +37,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
@@ -14,10 +15,10 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Landing fragment for sending gifts.
|
||||
@@ -78,8 +79,9 @@ class GiftFlowStartFragment : DSLSettingsFragment(
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
val days = state.giftBadge?.duration?.let { TimeUnit.MILLISECONDS.toDays(it) } ?: 60L
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(resources.getQuantityString(R.plurals.GiftFlowStartFragment__support_signal_by, 30, 30), DSLSettingsText.CenterModifier)
|
||||
title = DSLSettingsText.from(resources.getQuantityString(R.plurals.GiftFlowStartFragment__support_signal_by, days.toInt(), days), DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Displays a "Thank you" message in a conversation when redirected
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
@@ -38,7 +39,6 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
@@ -18,7 +19,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Handles all interactions for received gift badges.
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.badges.self.overview
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDo
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
|
||||
public class BlockedUsersFragment extends Fragment {
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Alignment.Companion.End
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
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.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
/**
|
||||
* Bottom sheet for creating call links
|
||||
*/
|
||||
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
private val viewModel: CreateCallLinkViewModel by viewModels()
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentSize(Alignment.Center)
|
||||
) {
|
||||
val callName: String by viewModel.callName
|
||||
val callLink: String by viewModel.callLink
|
||||
val approveAllMembers: Boolean by viewModel.approveAllMembers
|
||||
|
||||
Handle(modifier = Modifier.align(CenterHorizontally))
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__create_call_link),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SignalCallRow(
|
||||
callName = callName,
|
||||
callLink = callLink,
|
||||
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name),
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onAddACallNameClicked)
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = approveAllMembers,
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
|
||||
onCheckChanged = viewModel::setApproveAllMembers,
|
||||
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked)
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked)
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24),
|
||||
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked)
|
||||
)
|
||||
|
||||
Buttons.MediumTonal(
|
||||
onClick = this@CreateCallLinkBottomSheetDialogFragment::onDoneClicked,
|
||||
modifier = Modifier
|
||||
.padding(end = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.align(End)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__done))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddACallNameClicked() {
|
||||
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
|
||||
}
|
||||
|
||||
private fun onJoinClicked() {
|
||||
}
|
||||
|
||||
private fun onDoneClicked() {
|
||||
}
|
||||
|
||||
private fun onShareViaSignalClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(snapshot)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onCopyLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
Util.copyToClipboard(requireContext(), snapshot)
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun onShareLinkClicked() {
|
||||
val snapshot = viewModel.callLink.value
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(snapshot)
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignalCallRow(
|
||||
callName: String,
|
||||
callLink: String,
|
||||
onJoinClicked: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.border(
|
||||
width = 1.25.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
color = Color(0xFFE5E5FE),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
)
|
||||
Text(
|
||||
text = callLink,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Buttons.Small(
|
||||
onClick = onJoinClicked,
|
||||
modifier = Modifier.align(CenterVertically)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class CreateCallLinkViewModel : ViewModel() {
|
||||
private val _callName: MutableState<String> = mutableStateOf("")
|
||||
private val _callLink: MutableState<String> = mutableStateOf("")
|
||||
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
val callName: State<String> = _callName
|
||||
val callLink: State<String> = _callLink
|
||||
val approveAllMembers: State<Boolean> = _approveAllMembers
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
_approveAllMembers.value = approveAllMembers
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers() {
|
||||
_approveAllMembers.value = !_approveAllMembers.value
|
||||
}
|
||||
|
||||
fun setCallName(callName: String) {
|
||||
_callName.value = callName
|
||||
}
|
||||
|
||||
fun setCallLink(callLink: String) {
|
||||
_callLink.value = callLink
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Companion.End
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
|
||||
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
private val viewModel: CreateCallLinkViewModel by viewModels(
|
||||
ownerProducer = { requireParentFragment() }
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
return dialog
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
val viewModelCallName by viewModel.callName
|
||||
var callName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = viewModelCallName,
|
||||
selection = TextRange(viewModelCallName.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
|
||||
onNavigationClick = this::dismiss,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { paddingValues ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.core_ui__gutter)
|
||||
)
|
||||
.padding(top = 20.dp, bottom = 16.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = callName,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
|
||||
},
|
||||
onValueChange = { callName = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Buttons.MediumTonal(
|
||||
onClick = {
|
||||
viewModel.setCallName(callName.text)
|
||||
dismiss()
|
||||
},
|
||||
modifier = Modifier.align(End)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
|
||||
import org.thoughtcrime.securesms.databinding.CallLogCreateCallLinkItemBinding
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -51,6 +53,13 @@ class CallLogAdapter(
|
||||
inflater = ConversationListItemClearFilterBinding::inflate
|
||||
)
|
||||
)
|
||||
registerFactory(
|
||||
CreateCallLinkModel::class.java,
|
||||
BindingFactory(
|
||||
creator = { CreateCallLinkViewHolder(it, callbacks::onCreateACallLinkClicked) },
|
||||
inflater = CallLogCreateCallLinkItemBinding::inflate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(
|
||||
@@ -65,6 +74,7 @@ class CallLogAdapter(
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +122,12 @@ class CallLogAdapter(
|
||||
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CreateCallLinkModel : MappingModel<CreateCallLinkModel> {
|
||||
override fun areItemsTheSame(newItem: CreateCallLinkModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
@@ -136,31 +152,32 @@ class CallLogAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
val event = model.call.call.event
|
||||
val direction = model.call.call.direction
|
||||
val type = model.call.call.type
|
||||
|
||||
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)
|
||||
presentCallInfo(event, direction, model.call.date)
|
||||
presentCallType(type, model.call.peer)
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
presentCallType(model)
|
||||
}
|
||||
|
||||
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
|
||||
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
|
||||
val callState = context.getString(getCallStateStringRes(call.record))
|
||||
binding.callInfo.text = context.getString(
|
||||
R.string.CallLogAdapter__s_dot_s,
|
||||
context.getString(getCallStateStringRes(event, direction)),
|
||||
if (call.children.size > 1) {
|
||||
context.getString(R.string.CallLogAdapter__d_s, call.children.size, callState)
|
||||
} else {
|
||||
callState
|
||||
},
|
||||
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(
|
||||
start = getCallStateDrawableRes(event, direction)
|
||||
start = getCallStateDrawableRes(call.record)
|
||||
)
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (event == CallTable.Event.MISSED) {
|
||||
if (call.record.event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
@@ -175,45 +192,83 @@ class CallLogAdapter(
|
||||
binding.callInfo.setTextColor(color)
|
||||
}
|
||||
|
||||
private fun presentCallType(callType: CallTable.Type, peer: Recipient) {
|
||||
when (callType) {
|
||||
private fun presentCallType(model: CallModel) {
|
||||
when (model.call.record.type) {
|
||||
CallTable.Type.AUDIO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_phone_24)
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(peer) }
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(peer) }
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
|
||||
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
|
||||
when (model.call.groupCallState) {
|
||||
CallLogRow.GroupCallState.NONE, CallLogRow.GroupCallState.FULL -> {
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
|
||||
binding.callType.visible = false
|
||||
binding.groupCallButton.visible = true
|
||||
|
||||
binding.groupCallButton.setText(
|
||||
if (model.call.groupCallState == CallLogRow.GroupCallState.LOCAL_USER_JOINED) {
|
||||
R.string.CallLogAdapter__return
|
||||
} else {
|
||||
R.string.CallLogAdapter__join
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.callType.visible = true
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getCallStateDrawableRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
|
||||
if (callEvent == CallTable.Event.MISSED) {
|
||||
return R.drawable.symbol_missed_incoming_compact_16
|
||||
}
|
||||
|
||||
return if (callDirection == CallTable.Direction.INCOMING) {
|
||||
R.drawable.symbol_arrow_downleft_compact_16
|
||||
} else {
|
||||
R.drawable.symbol_arrow_upright_compact_16
|
||||
private fun getCallStateDrawableRes(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_compact_16
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16
|
||||
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
else -> error("Unexpected type ${call.type}")
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun getCallStateStringRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
|
||||
if (callEvent == CallTable.Event.MISSED) {
|
||||
return R.string.CallLogAdapter__missed
|
||||
}
|
||||
|
||||
return if (callDirection == CallTable.Direction.INCOMING) {
|
||||
R.string.CallLogAdapter__incoming
|
||||
} else {
|
||||
R.string.CallLogAdapter__outgoing
|
||||
private fun getCallStateStringRes(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__missed
|
||||
MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__missed
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.event == CallTable.Event.MISSED -> R.string.CallLogAdapter__missed
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
call.direction == CallTable.Direction.INCOMING -> R.string.CallLogAdapter__incoming
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
else -> error("Unexpected type ${call.messageType}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,7 +285,23 @@ class CallLogAdapter(
|
||||
override fun bind(model: ClearFilterModel) = Unit
|
||||
}
|
||||
|
||||
private class CreateCallLinkViewHolder(
|
||||
binding: CallLogCreateCallLinkItemBinding,
|
||||
onClick: () -> Unit
|
||||
) : BindingViewHolder<CreateCallLinkModel, CallLogCreateCallLinkItemBinding>(binding) {
|
||||
init {
|
||||
binding.root.setOnClickListener { onClick() }
|
||||
}
|
||||
|
||||
override fun bind(model: CreateCallLinkModel) = Unit
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
/**
|
||||
* Invoked when 'Create a call link' is clicked
|
||||
*/
|
||||
fun onCreateACallLinkClicked()
|
||||
|
||||
/**
|
||||
* Invoked when a call row is clicked
|
||||
*/
|
||||
|
||||
@@ -72,7 +72,7 @@ class CallLogContextMenu(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId))
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class CallLogContextMenu(
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.call.event == CallTable.Event.ONGOING) {
|
||||
if (call.record.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -22,6 +23,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
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.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
@@ -38,13 +40,15 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFi
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SearchBinder
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SnapToTopDataObserver
|
||||
import org.thoughtcrime.securesms.util.SnapToTopDataObserver.ScrollRequestValidator
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
@@ -100,6 +104,26 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
adapter.setPagingController(viewModel.controller)
|
||||
|
||||
val snapToTopDataObserver = SnapToTopDataObserver(
|
||||
binding.recycler,
|
||||
object : ScrollRequestValidator {
|
||||
override fun isPositionStillValid(position: Int): Boolean {
|
||||
return position < adapter.itemCount && position >= 0
|
||||
}
|
||||
|
||||
override fun isItemAtPositionLoaded(position: Int): Boolean {
|
||||
return adapter.getItem(position) != null
|
||||
}
|
||||
}
|
||||
) {
|
||||
val layoutManager = binding.recycler.layoutManager as? LinearLayoutManager ?: return@SnapToTopDataObserver
|
||||
if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) {
|
||||
binding.recycler.smoothScrollToPosition(0)
|
||||
} else {
|
||||
binding.recycler.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
@@ -144,7 +168,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
)
|
||||
|
||||
initializePullToFilter()
|
||||
initializeTapToScrollToTop()
|
||||
initializeTapToScrollToTop(snapToTopDataObserver)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
@@ -167,18 +191,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
initializeSearchAction()
|
||||
ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary()
|
||||
viewModel.markAllCallEventsRead()
|
||||
}
|
||||
|
||||
private fun initializeTapToScrollToTop() {
|
||||
private fun initializeTapToScrollToTop(snapToTopDataObserver: SnapToTopDataObserver) {
|
||||
disposables += tabsViewModel.tabClickEvents
|
||||
.filter { it == ConversationListTab.CALLS }
|
||||
.subscribeBy(onNext = {
|
||||
val layoutManager = binding.recycler.layoutManager as? LinearLayoutManager ?: return@subscribeBy
|
||||
if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) {
|
||||
binding.recycler.smoothScrollToPosition(0)
|
||||
} else {
|
||||
binding.recycler.scrollToPosition(0)
|
||||
}
|
||||
snapToTopDataObserver.requestScrollPosition(0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,11 +287,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateACallLinkClicked() {
|
||||
findNavController().navigate(R.id.createCallLinkBottomSheet)
|
||||
}
|
||||
|
||||
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, longArrayOf(callLogRow.call.messageId))
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
class CallLogPagedDataSource(
|
||||
private val query: String?,
|
||||
@@ -9,16 +10,24 @@ class CallLogPagedDataSource(
|
||||
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
|
||||
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
|
||||
|
||||
var callsCount = 0
|
||||
private var callsCount = 0
|
||||
|
||||
override fun size(): Int {
|
||||
callsCount = repository.getCallsCount(query, filter)
|
||||
return callsCount + (if (hasFilter) 1 else 0)
|
||||
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val calls: MutableList<CallLogRow> = repository.getCalls(query, filter, start, length).toMutableList()
|
||||
val calls = mutableListOf<CallLogRow>()
|
||||
val callLimit = length - hasCallLinkRow.toInt()
|
||||
|
||||
if (start == 0 && length >= 1 && hasCallLinkRow) {
|
||||
calls.add(CallLogRow.CreateCallLink)
|
||||
}
|
||||
|
||||
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
|
||||
|
||||
if (calls.size < length && hasFilter) {
|
||||
calls.add(CallLogRow.ClearFilter)
|
||||
@@ -31,6 +40,10 @@ class CallLogPagedDataSource(
|
||||
|
||||
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
|
||||
|
||||
private fun Boolean.toInt(): Int {
|
||||
return if (this) 1 else 0
|
||||
}
|
||||
|
||||
interface CallRepository {
|
||||
fun getCallsCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.calls.log
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
@@ -16,6 +17,12 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
SignalDatabase.messages.markAllCallEventsRead()
|
||||
}
|
||||
}
|
||||
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
@@ -26,33 +33,28 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
refresh()
|
||||
}
|
||||
|
||||
val messageObserver = DatabaseObserver.MessageObserver {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().registerCallUpdateObserver(databaseObserver)
|
||||
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedCallLogs(
|
||||
selectedMessageIds: Set<Long>
|
||||
selectedCallRowIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.messages.deleteCallUpdates(selectedMessageIds)
|
||||
SignalDatabase.calls.deleteCallEvents(selectedCallRowIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteAllCallLogsExcept(
|
||||
selectedMessageIds: Set<Long>
|
||||
selectedCallRowIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds)
|
||||
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,12 @@ sealed class CallLogRow {
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
data class Call(
|
||||
val call: CallTable.Call,
|
||||
val record: CallTable.Call,
|
||||
val peer: Recipient,
|
||||
val date: Long,
|
||||
override val id: Id = Id.Call(call.messageId)
|
||||
val groupCallState: GroupCallState,
|
||||
val children: Set<Long>,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
@@ -27,8 +30,57 @@ sealed class CallLogRow {
|
||||
override val id: Id = Id.ClearFilter
|
||||
}
|
||||
|
||||
object CreateCallLink : CallLogRow() {
|
||||
override val id: Id = Id.CreateCallLink
|
||||
}
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val messageId: Long) : Id()
|
||||
data class Call(val children: Set<Long>) : Id()
|
||||
object ClearFilter : Id()
|
||||
object CreateCallLink : Id()
|
||||
}
|
||||
|
||||
enum class GroupCallState {
|
||||
/**
|
||||
* No group call available.
|
||||
*/
|
||||
NONE,
|
||||
|
||||
/**
|
||||
* Active, but the local user is not in the call.
|
||||
*/
|
||||
ACTIVE,
|
||||
|
||||
/**
|
||||
* Active and the local user is in the call
|
||||
*/
|
||||
LOCAL_USER_JOINED,
|
||||
|
||||
/**
|
||||
* Active but the call is full.
|
||||
*/
|
||||
FULL;
|
||||
|
||||
companion object {
|
||||
fun fromDetails(groupCallUpdateDetails: GroupCallUpdateDetails?): GroupCallState {
|
||||
if (groupCallUpdateDetails == null) {
|
||||
return NONE
|
||||
}
|
||||
|
||||
if (groupCallUpdateDetails.isCallFull) {
|
||||
return FULL
|
||||
}
|
||||
|
||||
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireServiceId().uuid().toString())) {
|
||||
return LOCAL_USER_JOINED
|
||||
}
|
||||
|
||||
return if (groupCallUpdateDetails.inCallUuidsCount > 0) {
|
||||
ACTIVE
|
||||
} else {
|
||||
NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,16 @@ class CallLogStagedDeletion(
|
||||
}
|
||||
|
||||
isCommitted = true
|
||||
val messageIds = stateSnapshot.selected()
|
||||
val callRowIds = stateSnapshot.selected()
|
||||
.filterIsInstance<CallLogRow.Id.Call>()
|
||||
.map { it.messageId }
|
||||
.map { it.children }
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
if (stateSnapshot.isExclusionary()) {
|
||||
repository.deleteAllCallLogsExcept(messageIds).subscribe()
|
||||
repository.deleteAllCallLogsExcept(callRowIds).subscribe()
|
||||
} else {
|
||||
repository.deleteSelectedCallLogs(messageIds).subscribe()
|
||||
repository.deleteSelectedCallLogs(callRowIds).subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ class CallLogViewModel(
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
callLogRepository.markAllCallEventsRead()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
callLogStore.update {
|
||||
val selectionState = CallLogSelectionState.selectAll()
|
||||
|
||||
@@ -14,7 +14,6 @@ import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
@@ -38,6 +37,7 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType;
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
|
||||
@@ -65,6 +65,7 @@ public class ComposeText extends EmojiEditText {
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@@ -152,6 +153,9 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, getText(), getLayout());
|
||||
if (spoilerRendererDelegate != null) {
|
||||
spoilerRendererDelegate.draw(canvas, getText(), getLayout());
|
||||
}
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
@@ -298,6 +302,10 @@ public class ComposeText extends EmojiEditText {
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
}
|
||||
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
|
||||
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
|
||||
@@ -316,6 +324,10 @@ public class ComposeText extends EmojiEditText {
|
||||
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));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -330,7 +342,8 @@ public class ComposeText extends EmojiEditText {
|
||||
if (item.getItemId() != R.id.edittext_bold &&
|
||||
item.getItemId() != R.id.edittext_italic &&
|
||||
item.getItemId() != R.id.edittext_strikethrough &&
|
||||
item.getItemId() != R.id.edittext_monospace) {
|
||||
item.getItemId() != R.id.edittext_monospace &&
|
||||
item.getItemId() != R.id.edittext_spoiler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -339,7 +352,7 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
CharSequence charSequence = text.subSequence(start, end);
|
||||
SpannableString replacement = new SpannableString(charSequence);
|
||||
CharacterStyle style = null;
|
||||
Object style = null;
|
||||
|
||||
if (item.getItemId() == R.id.edittext_bold) {
|
||||
style = MessageStyler.boldStyle();
|
||||
@@ -349,6 +362,8 @@ public class ComposeText extends EmojiEditText {
|
||||
style = MessageStyler.strikethroughStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_monospace) {
|
||||
style = MessageStyler.monoStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_spoiler) {
|
||||
style = MessageStyler.spoilerStyle(MessageStyler.COMPOSE_ID, start, charSequence.length());
|
||||
}
|
||||
|
||||
if (style != null) {
|
||||
|
||||
@@ -6,9 +6,9 @@ import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.CharacterStyle
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler.isSupportedStyle
|
||||
|
||||
/**
|
||||
* Formatting should only grow when appending until a white space character is entered/pasted.
|
||||
@@ -44,37 +44,49 @@ class ComposeTextStyleWatcher : TextWatcher {
|
||||
|
||||
s.removeSpan(markerAnnotation)
|
||||
|
||||
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
|
||||
return
|
||||
}
|
||||
|
||||
val change = s.subSequence(editStart, editEnd)
|
||||
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
|
||||
textSnapshotPriorToChange = null
|
||||
return
|
||||
}
|
||||
textSnapshotPriorToChange = null
|
||||
|
||||
var newEnd = editStart
|
||||
for (i in change.indices) {
|
||||
if (StringUtil.isVisuallyEmpty(change[i])) {
|
||||
newEnd = editStart + i
|
||||
break
|
||||
try {
|
||||
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.getSpans(editStart, editEnd, CharacterStyle::class.java)
|
||||
.filter { MessageStyler.isSupportedCharacterStyle(it) }
|
||||
.forEach { style ->
|
||||
val styleStart = s.getSpanStart(style)
|
||||
val styleEnd = s.getSpanEnd(style)
|
||||
val change = s.subSequence(editStart, editEnd)
|
||||
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
|
||||
textSnapshotPriorToChange = null
|
||||
return
|
||||
}
|
||||
textSnapshotPriorToChange = null
|
||||
|
||||
if (styleEnd == editEnd && styleStart < styleEnd) {
|
||||
s.removeSpan(style)
|
||||
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
|
||||
} else {
|
||||
s.removeSpan(style)
|
||||
var newEnd = editStart
|
||||
for (i in change.indices) {
|
||||
if (StringUtil.isVisuallyEmpty(change[i])) {
|
||||
newEnd = editStart + i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
s.getSpans(editStart, editEnd, Object::class.java)
|
||||
.filter { it.isSupportedStyle() }
|
||||
.forEach { style ->
|
||||
val styleStart = s.getSpanStart(style)
|
||||
val styleEnd = s.getSpanEnd(style)
|
||||
|
||||
if (styleEnd == editEnd && styleStart < styleEnd) {
|
||||
s.removeSpan(style)
|
||||
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
|
||||
} else if (styleStart >= styleEnd) {
|
||||
s.removeSpan(style)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
s.getSpans(editStart, editEnd, Object::class.java)
|
||||
.filter { it.isSupportedStyle() }
|
||||
.forEach { style ->
|
||||
val styleStart = s.getSpanStart(style)
|
||||
val styleEnd = s.getSpanEnd(style)
|
||||
if (styleEnd == styleStart || styleStart > styleEnd) {
|
||||
s.removeSpan(style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -27,8 +28,10 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
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;
|
||||
@@ -82,7 +85,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
private ViewGroup mainView;
|
||||
private ViewGroup footerView;
|
||||
private TextView authorView;
|
||||
private TextView bodyView;
|
||||
private EmojiTextView bodyView;
|
||||
private View quoteBarView;
|
||||
private ShapeableImageView thumbnailView;
|
||||
private View attachmentVideoOverlayView;
|
||||
@@ -163,6 +166,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
setMessageType(messageType);
|
||||
|
||||
bodyView.enableSpoilerFiltering();
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,12 @@ public class TypingStatusRepository {
|
||||
ThreadUtil.cancelRunnableOnMain(timer);
|
||||
}
|
||||
|
||||
timer = () -> onTypingStopped(context, threadId, author, device, false);
|
||||
timer = () -> onTypingStopped(threadId, author, device, false);
|
||||
ThreadUtil.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT);
|
||||
timers.put(typist, timer);
|
||||
}
|
||||
|
||||
public synchronized void onTypingStopped(@NonNull Context context, long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) {
|
||||
public synchronized void onTypingStopped(long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) {
|
||||
if (author.isSelf()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Annotation;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextDirectionHeuristic;
|
||||
@@ -31,8 +33,10 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Arrays;
|
||||
@@ -65,8 +69,11 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private TextDirectionHeuristic textDirection;
|
||||
private boolean isJumbomoji;
|
||||
private boolean forceJumboEmoji;
|
||||
private boolean isInOnDraw;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
@@ -88,31 +95,56 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
a.recycle();
|
||||
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
|
||||
a = context.obtainStyledAttributes(attrs, new int[] { android.R.attr.textSize });
|
||||
originalFontSize = a.getDimensionPixelSize(0, 0);
|
||||
a.recycle();
|
||||
|
||||
if (renderMentions) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
|
||||
}
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
|
||||
|
||||
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
|
||||
|
||||
setEmojiCompatEnabled(useSystemEmoji());
|
||||
}
|
||||
|
||||
public void enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory();
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
isInOnDraw = true;
|
||||
|
||||
boolean hasSpannedText = getText() instanceof Spanned;
|
||||
boolean hasLayout = getLayout() != null;
|
||||
|
||||
if (hasSpannedText && hasLayout) {
|
||||
drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
|
||||
}
|
||||
|
||||
super.onDraw(canvas);
|
||||
|
||||
if (hasSpannedText && !hasLayout && getLayout() != null) {
|
||||
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
|
||||
}
|
||||
|
||||
isInOnDraw = false;
|
||||
}
|
||||
|
||||
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
if (mentionDelegate != null) {
|
||||
mentionDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
}
|
||||
spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -144,13 +176,18 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
useSystemEmoji = useSystemEmoji();
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
|
||||
Spannable textToSet;
|
||||
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
super.setText(new SpannableStringBuilder(Optional.ofNullable(text).orElse("")), BufferType.SPANNABLE);
|
||||
textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse(""));
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
|
||||
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
|
||||
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)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
|
||||
@@ -192,7 +229,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
int start = layout.getLineStart(lines - 1);
|
||||
|
||||
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) ||
|
||||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) {
|
||||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length())))
|
||||
{
|
||||
lastLineWidth = getMeasuredWidth();
|
||||
} else {
|
||||
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
|
||||
@@ -278,12 +316,19 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
if (maxLength > 0 && getText().length() > maxLength + 1) {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
|
||||
CharSequence shortenedText = getText().subSequence(0, maxLength);
|
||||
if (shortenedText instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) shortenedText;
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength);
|
||||
if (!mentionAnnotations.isEmpty()) {
|
||||
shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0)));
|
||||
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
|
||||
if (!mentionAnnotations.isEmpty()) {
|
||||
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
|
||||
}
|
||||
|
||||
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
|
||||
for (Object span : endSpans) {
|
||||
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
|
||||
int start = shortenedText.getSpanStart(span);
|
||||
int end = shortenedText.getSpanEnd(span);
|
||||
shortenedText.removeSpan(span);
|
||||
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,12 +338,18 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
|
||||
|
||||
Spannable newTextToSet;
|
||||
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
newTextToSet = newContent;
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
spoilerFilteringSpannableFactory.wrap(newTextToSet);
|
||||
}
|
||||
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +369,10 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
return;
|
||||
}
|
||||
|
||||
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
|
||||
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
|
||||
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, overflowStart))
|
||||
@@ -352,16 +403,16 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
|
||||
return Util.equals(previousText, text) &&
|
||||
return Util.equals(previousText, text) &&
|
||||
Util.equals(previousOverflowText, overflowText) &&
|
||||
Util.equals(previousBufferType, bufferType) &&
|
||||
useSystemEmoji == useSystemEmoji() &&
|
||||
!sizeChangeInProgress &&
|
||||
Util.equals(previousBufferType, bufferType) &&
|
||||
useSystemEmoji == useSystemEmoji() &&
|
||||
!sizeChangeInProgress &&
|
||||
previousTransformationMethod == getTransformationMethod();
|
||||
}
|
||||
|
||||
private boolean useSystemEmoji() {
|
||||
return isInEditMode() || (!forceCustom && SignalStore.settings().isPreferSystemEmoji());
|
||||
return isInEditMode() || (!forceCustom && SignalStore.settings().isPreferSystemEmoji());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -378,7 +429,13 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
@Override
|
||||
public void invalidateDrawable(@NonNull Drawable drawable) {
|
||||
if (drawable instanceof EmojiProvider.EmojiDrawable) invalidate();
|
||||
else super.invalidateDrawable(drawable);
|
||||
else super.invalidateDrawable(drawable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTextColor(int color) {
|
||||
super.setTextColor(color);
|
||||
spoilerRendererDelegate.updateFromTextColor();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -397,4 +454,15 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
mentionRendererDelegate.setTint(mentionBackgroundTint);
|
||||
}
|
||||
}
|
||||
|
||||
private class SpoilerFilteringSpannableFactory extends Spannable.Factory {
|
||||
@Override
|
||||
public @NonNull Spannable newSpannable(CharSequence source) {
|
||||
return wrap(super.newSpannable(source));
|
||||
}
|
||||
|
||||
@NonNull SpoilerFilteringSpannable wrap(Spannable source) {
|
||||
return new SpoilerFilteringSpannable(source, () -> isInOnDraw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import java.util.Optional
|
||||
@@ -16,9 +19,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
|
||||
private var bufferType: BufferType? = null
|
||||
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
|
||||
private val spoilerRendererDelegate: SpoilerRendererDelegate
|
||||
|
||||
init {
|
||||
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
|
||||
spoilerRendererDelegate = SpoilerRendererDelegate(this)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (text is Spanned && layout != null) {
|
||||
val checkpoint = canvas.save()
|
||||
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
|
||||
try {
|
||||
spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
|
||||
@@ -121,6 +121,13 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
|
||||
onClick = {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.Center
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
class ExportAccountDataFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(ExportAccountDataFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: ExportAccountDataViewModel by viewModels()
|
||||
|
||||
private val disposables = LifecycleDisposable()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
}
|
||||
|
||||
private fun exportReport() {
|
||||
disposables += viewModel.onGenerateReport()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { report ->
|
||||
ShareCompat.IntentBuilder(requireContext())
|
||||
.setStream(report.uri)
|
||||
.setType(report.mimeType)
|
||||
.startChooser()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissExportDialog() {
|
||||
viewModel.dismissExportConfirmationDialog()
|
||||
}
|
||||
|
||||
private fun dismissDownloadErrorDialog() {
|
||||
viewModel.dismissDownloadErrorDialog()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state: ExportAccountDataState by viewModel.state
|
||||
|
||||
val onNavigationClick: () -> Unit = remember {
|
||||
{ findNavController().popBackStack() }
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.AccountSettingsFragment__request_account_data),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.wrapContentSize()
|
||||
) {
|
||||
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.export_account_data),
|
||||
contentDescription = stringResource(R.string.ExportAccountDataFragment__your_account_data),
|
||||
modifier = Modifier.padding(top = 47.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__your_account_data),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val learnMore = stringResource(R.string.ExportAccountDataFragment__learn_more)
|
||||
val explanation = stringResource(R.string.ExportAccountDataFragment__export_explanation, learnMore)
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(explanation, learnMore, stringResource(R.string.export_account_data_url)),
|
||||
onUrlClick = { url ->
|
||||
CommunicationActions.openBrowserLink(requireContext(), url)
|
||||
},
|
||||
modifier = Modifier.padding(top = 12.dp, start = 32.dp, end = 32.dp, bottom = 20.dp),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ExportReportOptions(exportAsJson = state.exportAsJson)
|
||||
}
|
||||
}
|
||||
if (state.downloadInProgress) {
|
||||
DownloadProgressDialog()
|
||||
} else if (state.showDownloadFailedDialog) {
|
||||
DownloadFailedDialog()
|
||||
} else if (state.showExportDialog) {
|
||||
ExportReportConfirmationDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadProgressDialog() {
|
||||
Dialog(
|
||||
onDismissRequest = {},
|
||||
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
) {
|
||||
Card {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
verticalArrangement = Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(top = 50.dp, bottom = 18.dp)
|
||||
.size(42.dp)
|
||||
)
|
||||
Text(text = stringResource(R.string.ExportAccountDataFragment__download_progress), Modifier.padding(bottom = 48.dp, start = 35.dp, end = 35.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadFailedDialog() {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(id = R.string.ExportAccountDataFragment__check_network),
|
||||
dismiss = stringResource(id = R.string.ExportAccountDataFragment__ok_action),
|
||||
title = stringResource(id = R.string.ExportAccountDataFragment__report_generation_failed),
|
||||
onDismiss = this::dismissDownloadErrorDialog
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExportReportConfirmationDialog() {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation),
|
||||
body = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation_message),
|
||||
confirm = stringResource(R.string.ExportAccountDataFragment__export_report_action),
|
||||
dismiss = stringResource(R.string.ExportAccountDataFragment__cancel_action),
|
||||
onConfirm = this::exportReport,
|
||||
onDismiss = this::dismissExportDialog
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExportReportOptions(exportAsJson: Boolean) {
|
||||
Rows.RadioRow(
|
||||
selected = !exportAsJson,
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt),
|
||||
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt_label),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = viewModel::setExportAsTxt)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Rows.RadioRow(
|
||||
selected = exportAsJson,
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_json),
|
||||
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_json_label),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = viewModel::setExportAsJson)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = viewModel::showExportConfirmationDialog,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, start = 32.dp, end = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ExportAccountDataFragment__export_report),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__report_not_stored_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.padding(top = 16.dp, start = 24.dp, end = 28.dp, bottom = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import java.io.IOException
|
||||
|
||||
class ExportAccountDataRepository(
|
||||
private val accountManager: SignalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager()
|
||||
) {
|
||||
|
||||
fun downloadAccountDataReport(exportAsJson: Boolean): Single<ExportedReport> {
|
||||
return Single.create {
|
||||
try {
|
||||
it.onSuccess(generateAccountDataReport(accountManager.accountDataReport, exportAsJson))
|
||||
} catch (e: IOException) {
|
||||
it.onError(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun generateAccountDataReport(report: String, exportAsJson: Boolean): ExportedReport {
|
||||
val mimeType: String
|
||||
val fileName: String
|
||||
if (exportAsJson) {
|
||||
mimeType = "application/json"
|
||||
fileName = "account-data.json"
|
||||
} else {
|
||||
mimeType = "text/plain"
|
||||
fileName = "account-data.txt"
|
||||
}
|
||||
|
||||
val tree: JsonNode = JsonUtils.getMapper().readTree(report)
|
||||
val dataStr = if (exportAsJson) {
|
||||
(tree as ObjectNode).remove("text")
|
||||
tree.toString()
|
||||
} else {
|
||||
tree["text"].asText()
|
||||
}
|
||||
|
||||
val uri = BlobProvider.getInstance()
|
||||
.forData(dataStr.encodeToByteArray())
|
||||
.withMimeType(mimeType)
|
||||
.withFileName(fileName)
|
||||
.createForSingleUseInMemory()
|
||||
|
||||
return ExportedReport(mimeType = mimeType, uri = uri)
|
||||
}
|
||||
|
||||
data class ExportedReport(val mimeType: String, val uri: Uri)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
data class ExportAccountDataState(
|
||||
val downloadInProgress: Boolean,
|
||||
val exportAsJson: Boolean,
|
||||
val showDownloadFailedDialog: Boolean = false,
|
||||
val showExportDialog: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.subjects.MaybeSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class ExportAccountDataViewModel(
|
||||
private val repository: ExportAccountDataRepository = ExportAccountDataRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExportAccountDataViewModel::class.java)
|
||||
}
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
ExportAccountDataState(downloadInProgress = false, exportAsJson = false)
|
||||
)
|
||||
|
||||
val state: State<ExportAccountDataState> = _state
|
||||
|
||||
fun onGenerateReport(): Maybe<ExportAccountDataRepository.ExportedReport> {
|
||||
_state.value = _state.value.copy(downloadInProgress = true)
|
||||
val maybe = MaybeSubject.create<ExportAccountDataRepository.ExportedReport>()
|
||||
disposables += repository.downloadAccountDataReport(state.value.exportAsJson)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ report ->
|
||||
_state.value = _state.value.copy(downloadInProgress = false)
|
||||
maybe.onSuccess(report)
|
||||
}, { throwable ->
|
||||
Log.e(TAG, throwable)
|
||||
_state.value = _state.value.copy(downloadInProgress = false, showDownloadFailedDialog = true)
|
||||
maybe.onComplete()
|
||||
})
|
||||
return maybe
|
||||
}
|
||||
|
||||
fun setExportAsJson() {
|
||||
_state.value = _state.value.copy(exportAsJson = true)
|
||||
}
|
||||
|
||||
fun setExportAsTxt() {
|
||||
_state.value = _state.value.copy(exportAsJson = false)
|
||||
}
|
||||
|
||||
fun dismissDownloadErrorDialog() {
|
||||
_state.value = _state.value.copy(showDownloadFailedDialog = false)
|
||||
}
|
||||
|
||||
fun showExportConfirmationDialog() {
|
||||
_state.value = _state.value.copy(showExportDialog = true)
|
||||
}
|
||||
|
||||
fun dismissExportConfirmationDialog() {
|
||||
_state.value = _state.value.copy(showExportDialog = false)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.util.Objects
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.appcompat.widget.Toolbar
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ package org.thoughtcrime.securesms.components.settings.app.internal.donor
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
class InternalDonorErrorConfigurationFragment : DSLSettingsFragment() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
@@ -16,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.google.android.material.textfield.TextInputLayout
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.EditTextUtil
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
@@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileNamePreset
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -19,10 +19,10 @@ import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleViewModel.SaveScheduleResult
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -7,9 +7,9 @@ import android.widget.TextView
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
@@ -28,7 +29,6 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSche
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
@@ -18,7 +19,6 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -17,7 +18,6 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.privacy.pnp
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -22,15 +24,27 @@ import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberSharingMode
|
||||
|
||||
class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: PhoneNumberPrivacySettingsViewModel by viewModels()
|
||||
private lateinit var statusBarNestedScrollConnection: StatusBarColorNestedScrollConnection
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
statusBarNestedScrollConnection = StatusBarColorNestedScrollConnection(requireActivity())
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
statusBarNestedScrollConnection.setColorImmediate()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
override fun FragmentContent() {
|
||||
val state: PhoneNumberPrivacySettingsState by viewModel.state
|
||||
val onNavigationClick: () -> Unit = remember {
|
||||
{ findNavController().popBackStack() }
|
||||
@@ -40,7 +54,8 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
title = stringResource(id = R.string.preferences_app_protection__phone_number),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close),
|
||||
modifier = Modifier.nestedScroll(statusBarNestedScrollConnection)
|
||||
) { contentPadding ->
|
||||
Box(modifier = Modifier.padding(contentPadding)) {
|
||||
LazyColumn {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -33,7 +34,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.util.Currency
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -28,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.st
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
@@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Pa
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
@@ -28,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableArrayListExtraCompat
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
@@ -87,7 +88,6 @@ import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
@@ -39,12 +40,18 @@ class ConversationSettingsRepository(
|
||||
private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context)
|
||||
) {
|
||||
|
||||
fun getCallEvents(callMessageIds: LongArray): Single<List<MessageRecord>> {
|
||||
return if (callMessageIds.isEmpty()) {
|
||||
fun getCallEvents(callRowIds: LongArray): Single<List<Pair<CallTable.Call, MessageRecord>>> {
|
||||
return if (callRowIds.isEmpty()) {
|
||||
Single.just(emptyList())
|
||||
} else {
|
||||
Single.fromCallable {
|
||||
SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence().toList()
|
||||
val callMap = SignalDatabase.calls.getCallsByRowIds(callRowIds.toList())
|
||||
val messageIds = callMap.values.mapNotNull { it.messageId }
|
||||
SignalDatabase.messages.getMessages(messageIds).iterator().asSequence()
|
||||
.filter { callMap.containsKey(it.id) }
|
||||
.map { callMap[it.id]!! to it }
|
||||
.sortedByDescending { it.first.timestamp }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ sealed class ConversationSettingsViewModel(
|
||||
}
|
||||
|
||||
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
|
||||
state.copy(calls = callRecords.map { CallPreference.Model(it) })
|
||||
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
|
||||
}
|
||||
|
||||
store.update(sharedMedia) { cursor, state ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
|
||||
@@ -21,12 +22,14 @@ object CallPreference {
|
||||
}
|
||||
|
||||
class Model(
|
||||
val call: CallTable.Call,
|
||||
val record: MessageRecord
|
||||
) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return record.type == newItem.record.type &&
|
||||
return call == newItem.call &&
|
||||
record.type == newItem.record.type &&
|
||||
record.isOutgoing == newItem.record.isOutgoing &&
|
||||
record.timestamp == newItem.record.timestamp &&
|
||||
record.id == newItem.record.id
|
||||
@@ -35,30 +38,44 @@ object CallPreference {
|
||||
|
||||
private class ViewHolder(binding: ConversationSettingsCallPreferenceItemBinding) : BindingViewHolder<Model, ConversationSettingsCallPreferenceItemBinding>(binding) {
|
||||
override fun bind(model: Model) {
|
||||
binding.callIcon.setImageResource(getCallIcon(model.record))
|
||||
binding.callType.text = getCallType(model.record)
|
||||
binding.callIcon.setImageResource(getCallIcon(model.call))
|
||||
binding.callType.text = getCallType(model.call)
|
||||
binding.callTime.text = getCallTime(model.record)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getCallIcon(messageRecord: MessageRecord): Int {
|
||||
return when (messageRecord.type) {
|
||||
private fun getCallIcon(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_24
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_24
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_24
|
||||
else -> error("Unexpected type ${messageRecord.type}")
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_24
|
||||
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
else -> error("Unexpected type ${call.type}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallType(messageRecord: MessageRecord): String {
|
||||
val id = when (messageRecord.type) {
|
||||
private fun getCallType(call: CallTable.Call): String {
|
||||
val id = when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE -> R.string.MessageRecord_missed_voice_call
|
||||
MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.string.MessageRecord_missed_video_call
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.MessageRecord_incoming_voice_call
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.MessageRecord_incoming_video_call
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
|
||||
else -> error("Unexpected type ${messageRecord.type}")
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.event == CallTable.Event.MISSED -> R.string.CallPreference__missed_group_call
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
else -> error("Unexpected type ${call.messageType}")
|
||||
}
|
||||
|
||||
return context.getString(id)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.Annotation
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Helper for applying spans to text that should be rendered as a spoiler. Also
|
||||
* tracks spoilers that have been revealed or not.
|
||||
*/
|
||||
object SpoilerAnnotation {
|
||||
|
||||
private const val SPOILER_ANNOTATION = "spoiler"
|
||||
private val revealedSpoilers = mutableSetOf<String>()
|
||||
|
||||
@JvmStatic
|
||||
fun spoilerAnnotation(hash: Int): Annotation {
|
||||
return Annotation(SPOILER_ANNOTATION, hash.toString())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isSpoilerAnnotation(annotation: Any): Boolean {
|
||||
return SPOILER_ANNOTATION == (annotation as? Annotation)?.key
|
||||
}
|
||||
|
||||
fun getSpoilerAndClickAnnotations(spanned: Spanned, start: Int = 0, end: Int = spanned.length): Map<Annotation, SpoilerClickableSpan?> {
|
||||
val spoilerAnnotations: Map<Pair<Int, Int>, Annotation> = spanned.getSpans(start, end, Annotation::class.java)
|
||||
.filter { isSpoilerAnnotation(it) }
|
||||
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
|
||||
|
||||
val spoilerClickSpans: Map<Pair<Int, Int>, SpoilerClickableSpan> = spanned.getSpans(start, end, SpoilerClickableSpan::class.java)
|
||||
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
|
||||
|
||||
return spoilerAnnotations
|
||||
.map { (position, annotation) ->
|
||||
annotation to spoilerClickSpans[position]
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSpoilerAnnotations(spanned: Spanned, start: Int, end: Int): List<Annotation> {
|
||||
return spanned
|
||||
.getSpans(start, end, Annotation::class.java)
|
||||
.filter { isSpoilerAnnotation(it) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resetRevealedSpoilers() {
|
||||
revealedSpoilers.clear()
|
||||
}
|
||||
|
||||
class SpoilerClickableSpan(private val spoiler: Annotation) : ClickableSpan() {
|
||||
val spoilerRevealed
|
||||
get() = revealedSpoilers.contains(spoiler.value)
|
||||
|
||||
override fun onClick(widget: View) {
|
||||
revealedSpoilers.add(spoiler.value)
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {
|
||||
if (!spoilerRevealed) {
|
||||
ds.color = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
/**
|
||||
* Drawable that animates a sparkle effect for spoilers.
|
||||
*/
|
||||
class SpoilerDrawable(@ColorInt color: Int) : Drawable() {
|
||||
|
||||
private val paint = Paint()
|
||||
|
||||
init {
|
||||
alpha = 255
|
||||
setTintColor(color)
|
||||
}
|
||||
|
||||
fun setTintColor(@ColorInt color: Int) {
|
||||
paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
paint.shader = SpoilerPaint.shader
|
||||
canvas.drawRect(bounds, paint)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
paint.alpha = alpha
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSPARENT", "android.graphics.PixelFormat"))
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSPARENT
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
throw UnsupportedOperationException("Call setTintColor")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Shader
|
||||
import androidx.annotation.MainThread
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A wrapper around a paint object that can be used to apply the spoiler effect.
|
||||
*
|
||||
* The intended usage pattern is to call [update] before your item needs to be drawn.
|
||||
* Then, draw with the [paint].
|
||||
*/
|
||||
object SpoilerPaint {
|
||||
|
||||
/**
|
||||
* A paint that can be used to apply the spoiler effect.
|
||||
*/
|
||||
var shader: BitmapShader? = null
|
||||
|
||||
private val SIZE = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 100.dp else 200.dp
|
||||
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
|
||||
|
||||
private var shaderBitmap: Bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)
|
||||
private var bufferBitmap: Bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)
|
||||
|
||||
private val bounds: Rect = Rect(0, 0, SIZE, SIZE)
|
||||
private val paddedBounds: Rect
|
||||
|
||||
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
|
||||
private val particlePaints = listOf(Paint(), Paint(), Paint())
|
||||
private var lastDrawTime: Long = 0
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
|
||||
private var particleCount = ((bounds.width() * bounds.height()) * PARTICLES_PER_PIXEL).toInt()
|
||||
private val allParticles = Array(3) { Array(particleCount) { Particle(random) } }
|
||||
private val allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
|
||||
|
||||
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
|
||||
|
||||
init {
|
||||
val strokeWidth = DimensionUnit.DP.toPixels(1.5f)
|
||||
|
||||
particlePaints.forEachIndexed { index, paint ->
|
||||
paint.alpha = (255 * alphaStrength[index]).toInt()
|
||||
paint.strokeCap = Paint.Cap.ROUND
|
||||
paint.strokeWidth = strokeWidth
|
||||
}
|
||||
|
||||
paddedBounds = Rect(
|
||||
bounds.left - strokeWidth.toInt(),
|
||||
bounds.top - strokeWidth.toInt(),
|
||||
bounds.right + strokeWidth.toInt(),
|
||||
bounds.bottom + strokeWidth.toInt()
|
||||
)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke every time before you need to use the [paint].
|
||||
*/
|
||||
@MainThread
|
||||
fun update() {
|
||||
val now = System.currentTimeMillis()
|
||||
var dt = now - lastDrawTime
|
||||
if (dt < 32) {
|
||||
return
|
||||
} else if (dt > 48) {
|
||||
dt = 32
|
||||
}
|
||||
lastDrawTime = now
|
||||
|
||||
// The shader draws the live contents of the bitmap at potentially any point.
|
||||
// That means that if we draw directly to the in-use bitmap, it could potentially flicker.
|
||||
// To avoid that, we draw into a buffer, then swap the buffer into the shader when it's fully drawn.
|
||||
val canvas = Canvas(bufferBitmap)
|
||||
bufferBitmap.eraseColor(Color.TRANSPARENT)
|
||||
draw(canvas, dt)
|
||||
|
||||
val swap = shaderBitmap
|
||||
shaderBitmap = bufferBitmap
|
||||
bufferBitmap = swap
|
||||
|
||||
shader = BitmapShader(shaderBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the newest particle state into the canvas based on [dt].
|
||||
*
|
||||
* Note that we use [paddedBounds] here so that the draw space starts just outside the visible area.
|
||||
*
|
||||
* This is because we're going to end up tiling this, and being fuzzy around the edges helps reduce
|
||||
* the visual "gaps" between the tiles.
|
||||
*/
|
||||
private fun draw(canvas: Canvas, dt: Long) {
|
||||
for (allIndex in allParticles.indices) {
|
||||
val particles = allParticles[allIndex]
|
||||
for (index in 0 until particleCount) {
|
||||
val particle = particles[index]
|
||||
|
||||
particle.timeRemaining = particle.timeRemaining - dt
|
||||
if (particle.timeRemaining < 0 || !paddedBounds.contains(particle.x.toInt(), particle.y.toInt())) {
|
||||
particle.x = (random.nextFloat() * paddedBounds.width()) + paddedBounds.left
|
||||
particle.y = (random.nextFloat() * paddedBounds.height()) + paddedBounds.top
|
||||
particle.xVel = nextDirection()
|
||||
particle.yVel = nextDirection()
|
||||
particle.timeRemaining = 350 + 750 * random.nextFloat()
|
||||
} else {
|
||||
val change = dt * velocity
|
||||
particle.x += particle.xVel * change
|
||||
particle.y += particle.yVel * change
|
||||
}
|
||||
|
||||
allPoints[allIndex][index * 2] = particle.x
|
||||
allPoints[allIndex][index * 2 + 1] = particle.y
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPoints(allPoints[0], 0, particleCount * 2, particlePaints[0])
|
||||
canvas.drawPoints(allPoints[1], 0, particleCount * 2, particlePaints[1])
|
||||
canvas.drawPoints(allPoints[2], 0, particleCount * 2, particlePaints[2])
|
||||
}
|
||||
|
||||
private fun nextDirection(): Float {
|
||||
val rand = random.nextFloat()
|
||||
return if (rand < 0.5f) {
|
||||
0.1f + 0.9f * rand
|
||||
} else {
|
||||
-0.1f - 0.9f * (rand - 0.5f)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Particle(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var xVel: Float,
|
||||
var yVel: Float,
|
||||
var timeRemaining: Float
|
||||
) {
|
||||
constructor(random: Random) : this(
|
||||
x = -1f,
|
||||
y = -1f,
|
||||
xVel = if (random.nextFloat() < 0.5f) 1f else -1f,
|
||||
yVel = if (random.nextFloat() < 0.5f) 1f else -1f,
|
||||
timeRemaining = 500 + 1000 * random.nextFloat()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.text.Layout
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil
|
||||
|
||||
/**
|
||||
* Handles drawing the spoiler sparkles for a TextView.
|
||||
*/
|
||||
abstract class SpoilerRenderer {
|
||||
|
||||
abstract 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
|
||||
) {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun drawStart(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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Canvas
|
||||
import android.text.Annotation
|
||||
import android.text.Layout
|
||||
import android.text.Spanned
|
||||
import android.view.View
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.TextView
|
||||
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.
|
||||
*/
|
||||
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
|
||||
|
||||
private val single: SpoilerRenderer
|
||||
private val multi: SpoilerRenderer
|
||||
private val spoilerDrawable: SpoilerDrawable
|
||||
private var animatorRunning = false
|
||||
private var textColor: Int
|
||||
|
||||
private val cachedAnnotations = HashMap<Int, Map<Annotation, SpoilerClickableSpan?>>()
|
||||
private val cachedMeasurements = HashMap<Int, SpanMeasurements>()
|
||||
|
||||
private val animator = ValueAnimator.ofInt(0, 100).apply {
|
||||
duration = 1000
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener {
|
||||
SpoilerPaint.update()
|
||||
view.invalidate()
|
||||
}
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.REVERSE
|
||||
}
|
||||
|
||||
init {
|
||||
textColor = view.textColors.defaultColor
|
||||
spoilerDrawable = SpoilerDrawable(textColor)
|
||||
single = SingleLineSpoilerRenderer(spoilerDrawable)
|
||||
multi = MultiLineSpoilerRenderer(spoilerDrawable)
|
||||
|
||||
view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
|
||||
override fun onViewDetachedFromWindow(v: View) = stopAnimating()
|
||||
override fun onViewAttachedToWindow(v: View) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
fun updateFromTextColor() {
|
||||
val color = view.textColors.defaultColor
|
||||
if (color != textColor) {
|
||||
spoilerDrawable.setTintColor(color)
|
||||
textColor = color
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
|
||||
var hasSpoilersToRender = false
|
||||
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
|
||||
|
||||
for ((annotation, clickSpan) in annotations.entries) {
|
||||
if (clickSpan?.spoilerRevealed == true) {
|
||||
continue
|
||||
}
|
||||
|
||||
val spanStart: Int = text.getSpanStart(annotation)
|
||||
val spanEnd: Int = text.getSpanEnd(annotation)
|
||||
if (spanStart >= spanEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
val measurements = cachedMeasurements.getFromCache(annotation.value, layout) {
|
||||
val startLine = layout.getLineForOffset(spanStart)
|
||||
val endLine = layout.getLineForOffset(spanEnd)
|
||||
SpanMeasurements(
|
||||
startLine = startLine,
|
||||
endLine = endLine,
|
||||
startOffset = (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine)).toInt(),
|
||||
endOffset = (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine)).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (hasSpoilersToRender) {
|
||||
if (!animatorRunning) {
|
||||
animator.start()
|
||||
animatorRunning = true
|
||||
}
|
||||
} else {
|
||||
stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAnimating() {
|
||||
animator.pause()
|
||||
animatorRunning = false
|
||||
}
|
||||
|
||||
private inline fun <V> MutableMap<Int, V>.getFromCache(vararg keys: Any, default: () -> V): V {
|
||||
if (renderForComposing) {
|
||||
return default()
|
||||
}
|
||||
return getOrPut(keys.contentHashCode(), default)
|
||||
}
|
||||
|
||||
private data class SpanMeasurements(
|
||||
val startLine: Int,
|
||||
val endLine: Int,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(31)
|
||||
interface OnAudioOutputChangedListener31 {
|
||||
fun audioOutputChanged(audioDeviceId: Int)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import org.thoughtcrime.securesms.R;
|
||||
public enum WebRtcAudioOutput {
|
||||
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_24),
|
||||
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.symbol_speaker_fill_white_24),
|
||||
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.symbol_speaker_bluetooth_fill_white_24);
|
||||
BLUETOOTH_HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.symbol_speaker_bluetooth_fill_white_24),
|
||||
WIRED_HEADSET(R.string.WebRtcAudioOutputToggle__wired_headset, R.drawable.symbol_headphones_filed_24);
|
||||
|
||||
private final @StringRes int labelRes;
|
||||
private final @DrawableRes int iconRes;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
|
||||
/**
|
||||
* A bottom sheet that allows the user to select what device they want to route audio to. Intended to be used with Android 31+ APIs.
|
||||
*/
|
||||
class WebRtcAudioOutputBottomSheet : ComposeBottomSheetDialogFragment(), DialogInterface {
|
||||
private val viewModel by viewModels<AudioOutputViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.wrapContentSize()
|
||||
) {
|
||||
Handle()
|
||||
DeviceList(audioOutputOptions = viewModel.audioRoutes.toImmutableList(), initialDeviceId = viewModel.defaultDeviceId, modifier = Modifier.fillMaxWidth(), onDeviceSelected = viewModel.onClick)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
fun show(fm: FragmentManager, tag: String?, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit) {
|
||||
super.showNow(fm, tag)
|
||||
viewModel.audioRoutes = audioRoutes
|
||||
viewModel.defaultDeviceId = selectedDeviceId
|
||||
viewModel.onClick = onClick
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "WebRtcAudioOutputBottomSheet"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit): WebRtcAudioOutputBottomSheet {
|
||||
val bottomSheet = WebRtcAudioOutputBottomSheet()
|
||||
val args = Bundle()
|
||||
bottomSheet.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG, audioRoutes, selectedDeviceId, onClick)
|
||||
return bottomSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDeviceId: Int, modifier: Modifier = Modifier.fillMaxWidth(), onDeviceSelected: (AudioOutputOption) -> Unit) {
|
||||
var selectedDeviceId by rememberSaveable { mutableStateOf(initialDeviceId) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.WebRtcAudioOutputToggle__audio_output),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
)
|
||||
Column(Modifier.selectableGroup()) {
|
||||
audioOutputOptions.forEach { device: AudioOutputOption ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.selectable(
|
||||
selected = (device.deviceId == selectedDeviceId),
|
||||
onClick = {
|
||||
onDeviceSelected(device)
|
||||
selectedDeviceId = device.deviceId
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (device.deviceId == selectedDeviceId),
|
||||
onClick = null // null recommended for accessibility with screenreaders
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
painter = painterResource(id = getDrawableResourceForDeviceType(device.deviceType)),
|
||||
contentDescription = stringResource(id = getDescriptionStringResourceForDeviceType(device.deviceType)),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = device.friendlyName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioOutputViewModel : ViewModel() {
|
||||
var audioRoutes: List<AudioOutputOption> = emptyList()
|
||||
var defaultDeviceId: Int = -1
|
||||
var onClick: (AudioOutputOption) -> Unit = {}
|
||||
}
|
||||
|
||||
private fun getDrawableResourceForDeviceType(deviceType: SignalAudioManager.AudioDevice): Int {
|
||||
return when (deviceType) {
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> R.drawable.symbol_headphones_outline_24
|
||||
SignalAudioManager.AudioDevice.EARPIECE -> R.drawable.symbol_phone_speaker_outline_24
|
||||
SignalAudioManager.AudioDevice.BLUETOOTH -> R.drawable.symbol_speaker_bluetooth_fill_white_24
|
||||
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> R.drawable.symbol_speaker_outline_24
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDescriptionStringResourceForDeviceType(deviceType: SignalAudioManager.AudioDevice): Int {
|
||||
return when (deviceType) {
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> R.string.WebRtcAudioOutputBottomSheet__headset_icon_content_description
|
||||
SignalAudioManager.AudioDevice.EARPIECE -> R.string.WebRtcAudioOutputBottomSheet__earpiece_icon_content_description
|
||||
SignalAudioManager.AudioDevice.BLUETOOTH -> R.string.WebRtcAudioOutputBottomSheet__bluetooth_icon_content_description
|
||||
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> R.string.WebRtcAudioOutputBottomSheet__speaker_icon_content_description
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioOutputOption(val friendlyName: String, val deviceType: SignalAudioManager.AudioDevice, val deviceId: Int)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SampleOutputBottomSheet() {
|
||||
val outputs: ImmutableList<AudioOutputOption> = listOf(
|
||||
AudioOutputOption("Earpiece", SignalAudioManager.AudioDevice.EARPIECE, 0),
|
||||
AudioOutputOption("Speaker", SignalAudioManager.AudioDevice.SPEAKER_PHONE, 1),
|
||||
AudioOutputOption("BT Headset", SignalAudioManager.AudioDevice.BLUETOOTH, 2),
|
||||
AudioOutputOption("Wired Headset", SignalAudioManager.AudioDevice.WIRED_HEADSET, 3)
|
||||
).toImmutableList()
|
||||
DeviceList(outputs, 0) { }
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
|
||||
|
||||
private static final String STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index";
|
||||
private static final String STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled";
|
||||
private static final String STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled";
|
||||
private static final String STATE_PARENT = "audio.output.toggle.state.parent";
|
||||
|
||||
private static final int[] SPEAKER_OFF = { R.attr.state_speaker_off };
|
||||
private static final int[] SPEAKER_ON = { R.attr.state_speaker_on };
|
||||
private static final int[] OUTPUT_HANDSET = { R.attr.state_handset_selected };
|
||||
private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker_selected };
|
||||
private static final int[] OUTPUT_HEADSET = { R.attr.state_headset_selected };
|
||||
private static final int[][] OUTPUT_ENUM = { SPEAKER_OFF, SPEAKER_ON, OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET };
|
||||
private static final List<WebRtcAudioOutput> OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET);
|
||||
|
||||
private boolean isHeadsetAvailable;
|
||||
private boolean isHandsetAvailable;
|
||||
private int outputIndex;
|
||||
private OnAudioOutputChangedListener audioOutputChangedListener;
|
||||
private DialogInterface picker;
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
super.setOnClickListener((v) -> {
|
||||
List<WebRtcAudioOutput> availableModes = buildOutputModeList(isHeadsetAvailable, isHandsetAvailable);
|
||||
|
||||
if (availableModes.size() > 2 || !isHandsetAvailable) showPicker(availableModes);
|
||||
else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_MODES.size()), true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
hidePicker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] onCreateDrawableState(int extraSpace) {
|
||||
final int[] extra = OUTPUT_ENUM[outputIndex];
|
||||
final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length);
|
||||
mergeDrawableStates(drawableState, extra);
|
||||
return drawableState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(@Nullable OnClickListener l) {
|
||||
throw new UnsupportedOperationException("This View does not support custom click listeners.");
|
||||
}
|
||||
|
||||
public void setControlAvailability(boolean isHandsetAvailable, boolean isHeadsetAvailable) {
|
||||
this.isHandsetAvailable = isHandsetAvailable;
|
||||
this.isHeadsetAvailable = isHeadsetAvailable;
|
||||
}
|
||||
|
||||
public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput, boolean notifyListener) {
|
||||
int oldIndex = outputIndex;
|
||||
outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.lastIndexOf(audioOutput));
|
||||
|
||||
if (oldIndex != outputIndex) {
|
||||
refreshDrawableState();
|
||||
|
||||
if (notifyListener) {
|
||||
notifyListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnAudioOutputChangedListener(@Nullable OnAudioOutputChangedListener listener) {
|
||||
this.audioOutputChangedListener = listener;
|
||||
}
|
||||
|
||||
private void showPicker(@NonNull List<WebRtcAudioOutput> availableModes) {
|
||||
RecyclerView rv = new RecyclerView(getContext());
|
||||
AudioOutputAdapter adapter = new AudioOutputAdapter(audioOutput -> {
|
||||
setAudioOutput(audioOutput, true);
|
||||
hidePicker();
|
||||
},
|
||||
availableModes);
|
||||
|
||||
adapter.setSelectedOutput(OUTPUT_MODES.get(outputIndex));
|
||||
|
||||
rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
|
||||
rv.setAdapter(adapter);
|
||||
|
||||
picker = new MaterialAlertDialogBuilder(getContext())
|
||||
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
|
||||
.setView(rv)
|
||||
.setCancelable(true)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Parcelable onSaveInstanceState() {
|
||||
Parcelable parentState = super.onSaveInstanceState();
|
||||
Bundle bundle = new Bundle();
|
||||
|
||||
bundle.putParcelable(STATE_PARENT, parentState);
|
||||
bundle.putInt(STATE_OUTPUT_INDEX, outputIndex);
|
||||
bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable);
|
||||
bundle.putBoolean(STATE_HANDSET_ENABLED, isHandsetAvailable);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Parcelable state) {
|
||||
if (state instanceof Bundle) {
|
||||
Bundle savedState = (Bundle) state;
|
||||
|
||||
isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED);
|
||||
isHandsetAvailable = savedState.getBoolean(STATE_HANDSET_ENABLED);
|
||||
|
||||
setAudioOutput(OUTPUT_MODES.get(
|
||||
resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX))),
|
||||
false
|
||||
);
|
||||
|
||||
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
|
||||
} else {
|
||||
super.onRestoreInstanceState(state);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePicker() {
|
||||
if (picker != null) {
|
||||
picker.dismiss();
|
||||
picker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (audioOutputChangedListener == null) return;
|
||||
|
||||
audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex));
|
||||
}
|
||||
|
||||
private static List<WebRtcAudioOutput> buildOutputModeList(boolean isHeadsetAvailable, boolean isHandsetAvailable) {
|
||||
List<WebRtcAudioOutput> modes = new ArrayList(3);
|
||||
|
||||
modes.add(WebRtcAudioOutput.SPEAKER);
|
||||
|
||||
if (isHeadsetAvailable) {
|
||||
modes.add(WebRtcAudioOutput.HEADSET);
|
||||
}
|
||||
|
||||
if (isHandsetAvailable) {
|
||||
modes.add(WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
|
||||
return modes;
|
||||
};
|
||||
|
||||
private int resolveAudioOutputIndex(int desiredAudioOutputIndex) {
|
||||
if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) {
|
||||
throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex);
|
||||
}
|
||||
if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable, isHandsetAvailable)) {
|
||||
if (!isHandsetAvailable) {
|
||||
return OUTPUT_MODES.lastIndexOf(WebRtcAudioOutput.SPEAKER);
|
||||
} else {
|
||||
return OUTPUT_MODES.indexOf(WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeadsetAvailable) {
|
||||
return desiredAudioOutputIndex % 2;
|
||||
}
|
||||
|
||||
return desiredAudioOutputIndex;
|
||||
}
|
||||
|
||||
private static boolean isIllegalAudioOutputIndex(int desiredAudioOutputIndex) {
|
||||
return desiredAudioOutputIndex < 0 || desiredAudioOutputIndex > OUTPUT_MODES.size();
|
||||
}
|
||||
|
||||
private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable, boolean isHandsetAvailable) {
|
||||
return (OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable) ||
|
||||
(OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HANDSET && !isHandsetAvailable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.DialogInterface
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View.OnClickListener
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioDeviceMapping
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* A UI button that triggers a picker dialog/bottom sheet allowing the user to select the audio output for the ongoing call.
|
||||
*/
|
||||
class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
private val TAG = Log.tag(WebRtcAudioOutputToggleButton::class.java)
|
||||
|
||||
private var outputState: OutputState = OutputState()
|
||||
|
||||
private var audioOutputChangedListenerLegacy: OnAudioOutputChangedListener? = null
|
||||
private var audioOutputChangedListener31: OnAudioOutputChangedListener31? = null
|
||||
private var picker: DialogInterface? = null
|
||||
|
||||
private val clickListenerLegacy: OnClickListener = OnClickListener {
|
||||
val outputs = outputState.getOutputs()
|
||||
if (outputs.size >= SHOW_PICKER_THRESHOLD || !outputState.isEarpieceAvailable) {
|
||||
showPickerLegacy(outputs)
|
||||
} else {
|
||||
setAudioOutput(outputState.peekNext(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private val clickListener31 = OnClickListener {
|
||||
val fragmentActivity = context.fragmentActivity()
|
||||
if (fragmentActivity != null) {
|
||||
showPicker31(fragmentActivity.supportFragmentManager)
|
||||
} else {
|
||||
Log.e(TAG, "WebRtcAudioOutputToggleButton instantiated from a context that does not inherit from FragmentActivity.")
|
||||
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_fragment_activity_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
super.setOnClickListener(
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
clickListener31
|
||||
} else {
|
||||
clickListenerLegacy
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
hidePicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* DO NOT REMOVE THE ELVIS OPERATOR IN THE FIRST LINE
|
||||
* Somehow, through XML inflation (reflection?), [outputState] can actually be null,
|
||||
* even though the compiler disagrees.
|
||||
* */
|
||||
override fun onCreateDrawableState(extraSpace: Int): IntArray {
|
||||
val currentState = outputState ?: return super.onCreateDrawableState(extraSpace) // DO NOT REMOVE
|
||||
val currentOutput = currentState.getCurrentOutput()
|
||||
val extra = when (currentOutput) {
|
||||
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_handset_selected)
|
||||
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_selected)
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
|
||||
}
|
||||
val oldLabel = context.getString(currentOutput.labelRes)
|
||||
Log.i(TAG, "Switching drawable to $oldLabel")
|
||||
val drawableState = super.onCreateDrawableState(extraSpace + extra.size)
|
||||
mergeDrawableStates(drawableState, extra)
|
||||
return drawableState
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
throw UnsupportedOperationException("This View does not support custom click listeners.")
|
||||
}
|
||||
|
||||
fun setControlAvailability(isEarpieceAvailable: Boolean, isBluetoothHeadsetAvailable: Boolean) {
|
||||
outputState.isEarpieceAvailable = isEarpieceAvailable
|
||||
outputState.isBluetoothHeadsetAvailable = isBluetoothHeadsetAvailable
|
||||
}
|
||||
|
||||
fun setAudioOutput(audioOutput: WebRtcAudioOutput, notifyListener: Boolean) {
|
||||
val oldOutput = outputState.getCurrentOutput()
|
||||
if (oldOutput != audioOutput) {
|
||||
outputState.setCurrentOutput(audioOutput)
|
||||
refreshDrawableState()
|
||||
if (notifyListener) {
|
||||
audioOutputChangedListenerLegacy?.audioOutputChanged(audioOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnAudioOutputChangedListenerLegacy(listener: OnAudioOutputChangedListener?) {
|
||||
audioOutputChangedListenerLegacy = listener
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
fun setOnAudioOutputChangedListener31(listener: OnAudioOutputChangedListener31?) {
|
||||
audioOutputChangedListener31 = listener
|
||||
}
|
||||
|
||||
private fun showPickerLegacy(availableModes: List<WebRtcAudioOutput?>) {
|
||||
val rv = RecyclerView(context)
|
||||
val adapter = AudioOutputAdapter(
|
||||
{ audioOutput: WebRtcAudioOutput ->
|
||||
setAudioOutput(audioOutput, true)
|
||||
hidePicker()
|
||||
},
|
||||
availableModes
|
||||
)
|
||||
adapter.setSelectedOutput(outputState.getCurrentOutput())
|
||||
rv.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
||||
rv.adapter = adapter
|
||||
picker = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
|
||||
.setView(rv)
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private fun showPicker31(fragmentManager: FragmentManager) {
|
||||
val am = ApplicationDependencies.getAndroidCallAudioManager()
|
||||
if (am.availableCommunicationDevices.isEmpty()) {
|
||||
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }
|
||||
picker = WebRtcAudioOutputBottomSheet.show(fragmentManager, devices, am.communicationDevice?.id ?: -1) {
|
||||
audioOutputChangedListener31?.audioOutputChanged(it.deviceId)
|
||||
|
||||
when (it.deviceType) {
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> {
|
||||
outputState.isWiredHeadsetAvailable = true
|
||||
setAudioOutput(WebRtcAudioOutput.WIRED_HEADSET, true)
|
||||
}
|
||||
|
||||
SignalAudioManager.AudioDevice.EARPIECE -> {
|
||||
outputState.isEarpieceAvailable = true
|
||||
setAudioOutput(WebRtcAudioOutput.HANDSET, true)
|
||||
}
|
||||
|
||||
SignalAudioManager.AudioDevice.BLUETOOTH -> {
|
||||
outputState.isBluetoothHeadsetAvailable = true
|
||||
setAudioOutput(WebRtcAudioOutput.BLUETOOTH_HEADSET, true)
|
||||
}
|
||||
|
||||
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> setAudioOutput(WebRtcAudioOutput.SPEAKER, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(23)
|
||||
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
|
||||
return when (this.type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
|
||||
else -> this.productName
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val parentState = super.onSaveInstanceState()
|
||||
val bundle = Bundle()
|
||||
bundle.putParcelable(STATE_PARENT, parentState)
|
||||
bundle.putInt(STATE_OUTPUT_INDEX, outputState.getBackingIndexForBackup())
|
||||
bundle.putBoolean(STATE_HEADSET_ENABLED, outputState.isBluetoothHeadsetAvailable)
|
||||
bundle.putBoolean(STATE_HANDSET_ENABLED, outputState.isEarpieceAvailable)
|
||||
return bundle
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable) {
|
||||
if (state is Bundle) {
|
||||
outputState.isBluetoothHeadsetAvailable = state.getBoolean(STATE_HEADSET_ENABLED)
|
||||
outputState.isEarpieceAvailable = state.getBoolean(STATE_HANDSET_ENABLED)
|
||||
outputState.setBackingIndexForRestore(state.getInt(STATE_OUTPUT_INDEX))
|
||||
refreshDrawableState()
|
||||
super.onRestoreInstanceState(state.getParcelable(STATE_PARENT))
|
||||
} else {
|
||||
super.onRestoreInstanceState(state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hidePicker() {
|
||||
try {
|
||||
picker?.dismiss()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Picker is not attached to a window.")
|
||||
}
|
||||
|
||||
picker = null
|
||||
}
|
||||
|
||||
inner class OutputState {
|
||||
private val availableOutputs: LinkedHashSet<WebRtcAudioOutput> = linkedSetOf(WebRtcAudioOutput.SPEAKER)
|
||||
private var selectedDevice = 0
|
||||
set(value) {
|
||||
if (value >= availableOutputs.size) {
|
||||
throw IndexOutOfBoundsException("Index: $value, size: ${availableOutputs.size}")
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
@Deprecated("Used only for onSaveInstanceState.")
|
||||
fun getBackingIndexForBackup(): Int {
|
||||
return selectedDevice
|
||||
}
|
||||
|
||||
@Deprecated("Used only for onRestoreInstanceState.")
|
||||
fun setBackingIndexForRestore(index: Int) {
|
||||
selectedDevice = 0
|
||||
}
|
||||
|
||||
fun getCurrentOutput(): WebRtcAudioOutput {
|
||||
return getOutputs()[selectedDevice]
|
||||
}
|
||||
|
||||
fun setCurrentOutput(outputType: WebRtcAudioOutput): Boolean {
|
||||
val newIndex = getOutputs().indexOf(outputType)
|
||||
return if (newIndex < 0) {
|
||||
false
|
||||
} else {
|
||||
selectedDevice = newIndex
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun getOutputs(): List<WebRtcAudioOutput> {
|
||||
return availableOutputs.toList()
|
||||
}
|
||||
|
||||
fun peekNext(): WebRtcAudioOutput {
|
||||
val peekIndex = (selectedDevice + 1) % availableOutputs.size
|
||||
return getOutputs()[peekIndex]
|
||||
}
|
||||
|
||||
var isEarpieceAvailable: Boolean
|
||||
get() = availableOutputs.contains(WebRtcAudioOutput.HANDSET)
|
||||
set(value) {
|
||||
if (value) {
|
||||
availableOutputs.add(WebRtcAudioOutput.HANDSET)
|
||||
} else {
|
||||
availableOutputs.remove(WebRtcAudioOutput.HANDSET)
|
||||
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
var isBluetoothHeadsetAvailable: Boolean
|
||||
get() = availableOutputs.contains(WebRtcAudioOutput.BLUETOOTH_HEADSET)
|
||||
set(value) {
|
||||
if (value) {
|
||||
availableOutputs.add(WebRtcAudioOutput.BLUETOOTH_HEADSET)
|
||||
} else {
|
||||
availableOutputs.remove(WebRtcAudioOutput.BLUETOOTH_HEADSET)
|
||||
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
|
||||
}
|
||||
}
|
||||
var isWiredHeadsetAvailable: Boolean
|
||||
get() = availableOutputs.contains(WebRtcAudioOutput.WIRED_HEADSET)
|
||||
set(value) {
|
||||
if (value) {
|
||||
availableOutputs.add(WebRtcAudioOutput.WIRED_HEADSET)
|
||||
} else {
|
||||
availableOutputs.remove(WebRtcAudioOutput.WIRED_HEADSET)
|
||||
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHOW_PICKER_THRESHOLD = 3
|
||||
private const val STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index"
|
||||
private const val STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled"
|
||||
private const val STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled"
|
||||
private const val STATE_PARENT = "audio.output.toggle.state.parent"
|
||||
|
||||
private tailrec fun Context.fragmentActivity(): FragmentActivity? = when (this) {
|
||||
is FragmentActivity -> this
|
||||
else -> (this as? ContextWrapper)?.baseContext?.fragmentActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.graphics.ColorMatrix;
|
||||
import android.graphics.ColorMatrixColorFilter;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -16,7 +17,10 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
@@ -103,7 +107,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private View startCallControls;
|
||||
private ViewPager2 callParticipantsPager;
|
||||
private RecyclerView callParticipantsRecycler;
|
||||
private ConstraintLayout toolbar;
|
||||
private ConstraintLayout largeHeader;
|
||||
private MaterialButton startCall;
|
||||
private Stub<FrameLayout> groupCallSpeakerHint;
|
||||
private Stub<View> groupCallFullStub;
|
||||
@@ -113,15 +117,13 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private Guideline showParticipantsGuideline;
|
||||
private Guideline topFoldGuideline;
|
||||
private Guideline callScreenTopFoldGuideline;
|
||||
private View foldParticipantCountWrapper;
|
||||
private TextView foldParticipantCount;
|
||||
private AvatarImageView largeHeaderAvatar;
|
||||
private ConstraintSet largeHeaderConstraints;
|
||||
private ConstraintSet smallHeaderConstraints;
|
||||
private Guideline statusBarGuideline;
|
||||
private Guideline navigationBarGuideline;
|
||||
private int navBarBottomInset;
|
||||
private View fullScreenShade;
|
||||
private Toolbar collapsedToolbar;
|
||||
private Toolbar headerToolbar;
|
||||
|
||||
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
|
||||
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
|
||||
@@ -184,7 +186,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
startCallControls = findViewById(R.id.call_screen_start_call_controls);
|
||||
callParticipantsPager = findViewById(R.id.call_screen_participants_pager);
|
||||
callParticipantsRecycler = findViewById(R.id.call_screen_participants_recycler);
|
||||
toolbar = findViewById(R.id.call_screen_header);
|
||||
largeHeader = findViewById(R.id.call_screen_header);
|
||||
startCall = findViewById(R.id.call_screen_start_call_start_call);
|
||||
errorButton = findViewById(R.id.call_screen_error_cancel);
|
||||
groupCallSpeakerHint = new Stub<>(findViewById(R.id.call_screen_group_call_speaker_hint));
|
||||
@@ -192,12 +194,12 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
showParticipantsGuideline = findViewById(R.id.call_screen_show_participants_guideline);
|
||||
topFoldGuideline = findViewById(R.id.fold_top_guideline);
|
||||
callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline);
|
||||
foldParticipantCountWrapper = findViewById(R.id.fold_show_participants_menu_counter_wrapper);
|
||||
foldParticipantCount = findViewById(R.id.fold_show_participants_menu_counter);
|
||||
largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar);
|
||||
statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline);
|
||||
navigationBarGuideline = findViewById(R.id.call_screen_navigation_bar_guideline);
|
||||
fullScreenShade = findViewById(R.id.call_screen_full_shade);
|
||||
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
|
||||
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
|
||||
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
@@ -222,7 +224,9 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
}
|
||||
});
|
||||
|
||||
topViews.add(toolbar);
|
||||
topViews.add(collapsedToolbar);
|
||||
topViews.add(headerToolbar);
|
||||
topViews.add(largeHeader);
|
||||
topViews.add(topGradient);
|
||||
|
||||
incomingCallViews.add(answer);
|
||||
@@ -237,9 +241,16 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
adjustableMarginsSet.add(videoToggle);
|
||||
adjustableMarginsSet.add(audioToggle);
|
||||
|
||||
audioToggle.setOnAudioOutputChangedListener(outputMode -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
|
||||
});
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
audioToggle.setOnAudioOutputChangedListener31(deviceId -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged31(deviceId));
|
||||
});
|
||||
} else {
|
||||
audioToggle.setOnAudioOutputChangedListenerLegacy(outputMode -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
|
||||
});
|
||||
}
|
||||
|
||||
videoToggle.setOnCheckedChangeListener((v, isOn) -> {
|
||||
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn));
|
||||
@@ -293,6 +304,36 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
}
|
||||
});
|
||||
|
||||
collapsedToolbar.setNavigationOnClickListener(unused -> {
|
||||
if (controlsListener != null) {
|
||||
controlsListener.onNavigateUpClicked();
|
||||
}
|
||||
});
|
||||
|
||||
collapsedToolbar.setOnMenuItemClickListener(item -> {
|
||||
if (item.getItemId() == R.id.action_info && controlsListener != null) {
|
||||
controlsListener.onCallInfoClicked();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
headerToolbar.setNavigationOnClickListener(unused -> {
|
||||
if (controlsListener != null) {
|
||||
controlsListener.onNavigateUpClicked();
|
||||
}
|
||||
});
|
||||
|
||||
headerToolbar.setOnMenuItemClickListener(item -> {
|
||||
if (item.getItemId() == R.id.action_info && controlsListener != null) {
|
||||
controlsListener.onCallInfoClicked();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
rotatableControls.add(hangup);
|
||||
rotatableControls.add(answer);
|
||||
rotatableControls.add(answerWithoutVideo);
|
||||
@@ -303,12 +344,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
rotatableControls.add(decline);
|
||||
rotatableControls.add(smallLocalAudioIndicator);
|
||||
rotatableControls.add(ringToggle);
|
||||
|
||||
largeHeaderConstraints = new ConstraintSet();
|
||||
largeHeaderConstraints.clone(getContext(), R.layout.webrtc_call_view_header_large);
|
||||
|
||||
smallHeaderConstraints = new ConstraintSet();
|
||||
smallHeaderConstraints.clone(getContext(), R.layout.webrtc_call_view_header_small);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -348,9 +383,9 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
if ((visible & SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
|
||||
if (controls.adjustForFold()) {
|
||||
pictureInPictureGestureHelper.clearVerticalBoundaries();
|
||||
pictureInPictureGestureHelper.setTopVerticalBoundary(toolbar.getTop());
|
||||
pictureInPictureGestureHelper.setTopVerticalBoundary(largeHeader.getTop());
|
||||
} else {
|
||||
pictureInPictureGestureHelper.setTopVerticalBoundary(toolbar.getBottom());
|
||||
pictureInPictureGestureHelper.setTopVerticalBoundary(largeHeader.getBottom());
|
||||
pictureInPictureGestureHelper.setBottomVerticalBoundary(videoToggle.getTop());
|
||||
}
|
||||
} else {
|
||||
@@ -398,21 +433,21 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
if (state.getGroupCallState().isNotIdle()) {
|
||||
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
|
||||
status.setText(state.getPreJoinGroupDescription(getContext()));
|
||||
setStatus(state.getPreJoinGroupDescription(getContext()));
|
||||
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
|
||||
status.setText(state.getOutgoingRingingGroupDescription(getContext()));
|
||||
setStatus(state.getOutgoingRingingGroupDescription(getContext()));
|
||||
} else if (state.getGroupCallState().isRinging()) {
|
||||
status.setText(state.getIncomingRingingGroupDescription(getContext()));
|
||||
setStatus(state.getIncomingRingingGroupDescription(getContext()));
|
||||
}
|
||||
}
|
||||
|
||||
if (state.getGroupCallState().isNotIdle()) {
|
||||
String text = state.getParticipantCount()
|
||||
.mapToObj(String::valueOf).orElse("\u2014");
|
||||
boolean enabled = state.getParticipantCount().isPresent();
|
||||
|
||||
foldParticipantCount.setText(text);
|
||||
foldParticipantCount.setEnabled(enabled);
|
||||
collapsedToolbar.getMenu().getItem(0).setVisible(enabled);
|
||||
headerToolbar.getMenu().getItem(0).setVisible(enabled);
|
||||
} else {
|
||||
collapsedToolbar.getMenu().getItem(0).setVisible(false);
|
||||
headerToolbar.getMenu().getItem(0).setVisible(false);
|
||||
}
|
||||
|
||||
pagerAdapter.submitList(pages);
|
||||
@@ -518,34 +553,34 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
recipientId = recipient.getId();
|
||||
|
||||
largeHeaderAvatar.setRecipient(recipient, false);
|
||||
|
||||
if (recipient.isGroup()) {
|
||||
foldParticipantCountWrapper.setOnClickListener(unused -> showParticipantsList());
|
||||
}
|
||||
|
||||
collapsedToolbar.setTitle(recipient.getDisplayName(getContext()));
|
||||
recipientName.setText(recipient.getDisplayName(getContext()));
|
||||
}
|
||||
|
||||
public void setStatus(@NonNull String status) {
|
||||
public void setStatus(@Nullable String status) {
|
||||
this.status.setText(status);
|
||||
collapsedToolbar.setSubtitle(status);
|
||||
}
|
||||
|
||||
private void setStatus(@StringRes int statusRes) {
|
||||
setStatus(getContext().getString(statusRes));
|
||||
}
|
||||
|
||||
public void setStatusFromHangupType(@NonNull HangupMessage.Type hangupType) {
|
||||
switch (hangupType) {
|
||||
case NORMAL:
|
||||
case NEED_PERMISSION:
|
||||
status.setText(R.string.RedPhone_ending_call);
|
||||
setStatus(R.string.RedPhone_ending_call);
|
||||
break;
|
||||
case ACCEPTED:
|
||||
status.setText(R.string.WebRtcCallActivity__answered_on_a_linked_device);
|
||||
setStatus(R.string.WebRtcCallActivity__answered_on_a_linked_device);
|
||||
break;
|
||||
case DECLINED:
|
||||
status.setText(R.string.WebRtcCallActivity__declined_on_a_linked_device);
|
||||
setStatus(R.string.WebRtcCallActivity__declined_on_a_linked_device);
|
||||
break;
|
||||
case BUSY:
|
||||
status.setText(R.string.WebRtcCallActivity__busy_on_a_linked_device);
|
||||
setStatus(R.string.WebRtcCallActivity__busy_on_a_linked_device);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unknown hangup type: " + hangupType);
|
||||
@@ -555,18 +590,18 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
public void setStatusFromGroupCallState(@NonNull WebRtcViewModel.GroupCallState groupCallState) {
|
||||
switch (groupCallState) {
|
||||
case DISCONNECTED:
|
||||
status.setText(R.string.WebRtcCallView__disconnected);
|
||||
setStatus(R.string.WebRtcCallView__disconnected);
|
||||
break;
|
||||
case RECONNECTING:
|
||||
status.setText(R.string.WebRtcCallView__reconnecting);
|
||||
setStatus(R.string.WebRtcCallView__reconnecting);
|
||||
break;
|
||||
case CONNECTED_AND_JOINING:
|
||||
status.setText(R.string.WebRtcCallView__joining);
|
||||
setStatus(R.string.WebRtcCallView__joining);
|
||||
break;
|
||||
case CONNECTING:
|
||||
case CONNECTED_AND_JOINED:
|
||||
case CONNECTED:
|
||||
status.setText("");
|
||||
setStatus("");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -588,11 +623,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
callScreenTopFoldGuideline.setGuidelineEnd(0);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayGroupMembersButton()) {
|
||||
visibleViewSet.add(foldParticipantCountWrapper);
|
||||
foldParticipantCount.setClickable(webRtcControls.adjustForFold());
|
||||
}
|
||||
|
||||
if (webRtcControls.displayStartCallControls()) {
|
||||
visibleViewSet.add(footerGradient);
|
||||
visibleViewSet.add(startCallControls);
|
||||
@@ -639,8 +669,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
if (webRtcControls.displayAudioToggle()) {
|
||||
visibleViewSet.add(audioToggle);
|
||||
|
||||
audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(),
|
||||
webRtcControls.enableHeadsetInAudioToggle());
|
||||
audioToggle.setControlAvailability(webRtcControls.isEarpieceAvailableForAudioToggle(),
|
||||
webRtcControls.isBluetoothHeadsetAvailableForAudioToggle());
|
||||
|
||||
audioToggle.setAudioOutput(webRtcControls.getAudioOutput(), false);
|
||||
}
|
||||
@@ -846,7 +876,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void toggleControls() {
|
||||
if (controls.isFadeOutEnabled() && toolbar.getVisibility() == VISIBLE) {
|
||||
if (controls.isFadeOutEnabled() && largeHeader.getVisibility() == VISIBLE) {
|
||||
fadeOutControls();
|
||||
} else {
|
||||
fadeInControls();
|
||||
@@ -969,11 +999,19 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
constraintSet.applyTo(parent);
|
||||
|
||||
if (showSmallHeader) {
|
||||
smallHeaderConstraints.setVisibility(incomingRingStatus.getId(), visibleViewSet.contains(incomingRingStatus) ? View.VISIBLE : View.GONE);
|
||||
smallHeaderConstraints.applyTo(toolbar);
|
||||
collapsedToolbar.setEnabled(true);
|
||||
collapsedToolbar.setAlpha(1);
|
||||
headerToolbar.setEnabled(false);
|
||||
headerToolbar.setAlpha(0);
|
||||
largeHeader.setEnabled(false);
|
||||
largeHeader.setAlpha(0);
|
||||
} else {
|
||||
largeHeaderConstraints.setVisibility(incomingRingStatus.getId(), visibleViewSet.contains(incomingRingStatus) ? View.VISIBLE : View.GONE);
|
||||
largeHeaderConstraints.applyTo(toolbar);
|
||||
collapsedToolbar.setEnabled(false);
|
||||
collapsedToolbar.setAlpha(0);
|
||||
headerToolbar.setEnabled(true);
|
||||
headerToolbar.setAlpha(1);
|
||||
largeHeader.setEnabled(true);
|
||||
largeHeader.setAlpha(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,11 +1061,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle_small);
|
||||
}
|
||||
|
||||
private boolean showParticipantsList() {
|
||||
controlsListener.onShowParticipantsList();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void switchToSpeakerView() {
|
||||
if (pagerAdapter.getItemCount() > 0) {
|
||||
callParticipantsPager.setCurrentItem(pagerAdapter.getItemCount() - 1, false);
|
||||
@@ -1049,6 +1082,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
void showSystemUI();
|
||||
void hideSystemUI();
|
||||
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
|
||||
@RequiresApi(31)
|
||||
void onAudioOutputChanged31(@NonNull int audioOutputAddress);
|
||||
void onVideoChanged(boolean isVideoEnabled);
|
||||
void onMicChanged(boolean isMicEnabled);
|
||||
void onCameraDirectionChanged();
|
||||
@@ -1056,9 +1091,10 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
void onDenyCallPressed();
|
||||
void onAcceptCallWithVoiceOnlyPressed();
|
||||
void onAcceptCallPressed();
|
||||
void onShowParticipantsList();
|
||||
void onPageChanged(@NonNull CallParticipantsState.SelectedPage page);
|
||||
void onLocalPictureInPictureClicked();
|
||||
void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed);
|
||||
void onCallInfoClicked();
|
||||
void onNavigateUpClicked();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public final class WebRtcControls {
|
||||
}
|
||||
|
||||
boolean displayAudioToggle() {
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || enableHeadsetInAudioToggle());
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothHeadsetAvailableForAudioToggle());
|
||||
}
|
||||
|
||||
boolean displayCameraToggle() {
|
||||
@@ -172,11 +172,11 @@ public final class WebRtcControls {
|
||||
return isIncoming();
|
||||
}
|
||||
|
||||
boolean enableHandsetInAudioToggle() {
|
||||
boolean isEarpieceAvailableForAudioToggle() {
|
||||
return !isLocalVideoEnabled;
|
||||
}
|
||||
|
||||
boolean enableHeadsetInAudioToggle() {
|
||||
boolean isBluetoothHeadsetAvailableForAudioToggle() {
|
||||
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||
}
|
||||
|
||||
@@ -201,7 +201,9 @@ public final class WebRtcControls {
|
||||
case SPEAKER_PHONE:
|
||||
return WebRtcAudioOutput.SPEAKER;
|
||||
case BLUETOOTH:
|
||||
return WebRtcAudioOutput.HEADSET;
|
||||
return WebRtcAudioOutput.BLUETOOTH_HEADSET;
|
||||
case WIRED_HEADSET:
|
||||
return WebRtcAudioOutput.WIRED_HEADSET;
|
||||
default:
|
||||
return WebRtcAudioOutput.HANDSET;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD
|
||||
* ```
|
||||
*/
|
||||
@Composable
|
||||
protected fun Handle() {
|
||||
protected fun Handle(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.size(width = 48.dp, height = 22.dp)
|
||||
.padding(vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(1000.dp))
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.compose
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
/**
|
||||
* Generic ComposeFragment which can be subclassed to build UI with compose.
|
||||
*/
|
||||
abstract class ComposeDialogFragment : DialogFragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
DialogContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
abstract fun DialogContent()
|
||||
}
|
||||
@@ -23,12 +23,12 @@ abstract class ComposeFragment : LoggingFragment() {
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
SheetContent()
|
||||
FragmentContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
abstract fun SheetContent()
|
||||
abstract fun FragmentContent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms.compose
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Controls status-bar color based off a nested scroll
|
||||
*/
|
||||
class StatusBarColorNestedScrollConnection(
|
||||
private val activity: Activity
|
||||
) : NestedScrollConnection {
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
private val normalColor = ContextCompat.getColor(activity, R.color.signal_colorBackground)
|
||||
private val scrollColor = ContextCompat.getColor(activity, R.color.signal_colorSurface2)
|
||||
|
||||
private var contentOffset = 0f
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
val oldContentOffset = contentOffset
|
||||
if (consumed.y == 0f && available.y > 0f) {
|
||||
contentOffset = 0f
|
||||
} else {
|
||||
contentOffset += consumed.y
|
||||
}
|
||||
|
||||
if (oldContentOffset.isNearZero() xor contentOffset.isNearZero()) {
|
||||
applyState()
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
fun setColorImmediate() {
|
||||
val end = when {
|
||||
contentOffset.isNearZero() -> normalColor
|
||||
else -> scrollColor
|
||||
}
|
||||
|
||||
animator?.cancel()
|
||||
WindowUtil.setStatusBarColor(
|
||||
activity.window,
|
||||
end
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyState() {
|
||||
val (start, end) = when {
|
||||
contentOffset.isNearZero() -> scrollColor to normalColor
|
||||
else -> normalColor to scrollColor
|
||||
}
|
||||
|
||||
animator?.cancel()
|
||||
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 200
|
||||
addUpdateListener {
|
||||
WindowUtil.setStatusBarColor(
|
||||
activity.window,
|
||||
ArgbEvaluatorCompat.getInstance().evaluate(it.animatedFraction, start, end)
|
||||
)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.isNearZero(): Boolean = abs(this) < 0.001
|
||||
}
|
||||
@@ -233,7 +233,7 @@ class ContactSearchPagedDataSource(
|
||||
cursor.moveToPosition(-1)
|
||||
while (cursor.moveToNext()) {
|
||||
val sortName = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN))
|
||||
if (!sortName.first().isDigit()) {
|
||||
if (sortName.isNotEmpty() && !sortName.first().isDigit()) {
|
||||
return cursor.position
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ package org.thoughtcrime.securesms.contactshare;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment;
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
@@ -19,6 +24,8 @@ import static org.thoughtcrime.securesms.contactshare.Contact.PostalAddress;
|
||||
|
||||
public class ContactModelMapper {
|
||||
|
||||
private static final String TAG = Log.tag(ContactModelMapper.class);
|
||||
|
||||
public static SharedContact.Builder localToRemoteBuilder(@NonNull Contact contact) {
|
||||
List<SharedContact.Phone> phoneNumbers = new ArrayList<>(contact.getPhoneNumbers().size());
|
||||
List<SharedContact.Email> emails = new ArrayList<>(contact.getEmails().size());
|
||||
@@ -118,6 +125,57 @@ public class ContactModelMapper {
|
||||
return new Contact(name, sharedContact.getOrganization().orElse(null), phoneNumbers, emails, postalAddresses, avatar);
|
||||
}
|
||||
|
||||
public static Contact remoteToLocal(@NonNull SignalServiceProtos.DataMessage.Contact contact) {
|
||||
Name name = new Name(contact.getName().getDisplayName(),
|
||||
contact.getName().getGivenName(),
|
||||
contact.getName().getFamilyName(),
|
||||
contact.getName().getPrefix(),
|
||||
contact.getName().getSuffix(),
|
||||
contact.getName().getMiddleName());
|
||||
|
||||
List<Phone> phoneNumbers = new ArrayList<>(contact.getNumberCount());
|
||||
for (SignalServiceProtos.DataMessage.Contact.Phone phone : contact.getNumberList()) {
|
||||
phoneNumbers.add(new Phone(phone.getValue(),
|
||||
remoteToLocalType(phone.getType()),
|
||||
phone.getLabel()));
|
||||
}
|
||||
|
||||
List<Email> emails = new ArrayList<>(contact.getEmailCount());
|
||||
for (SignalServiceProtos.DataMessage.Contact.Email email : contact.getEmailList()) {
|
||||
emails.add(new Email(email.getValue(),
|
||||
remoteToLocalType(email.getType()),
|
||||
email.getLabel()));
|
||||
}
|
||||
|
||||
List<PostalAddress> postalAddresses = new ArrayList<>(contact.getAddressCount());
|
||||
for (SignalServiceProtos.DataMessage.Contact.PostalAddress postalAddress : contact.getAddressList()) {
|
||||
postalAddresses.add(new PostalAddress(remoteToLocalType(postalAddress.getType()),
|
||||
postalAddress.getLabel(),
|
||||
postalAddress.getStreet(),
|
||||
postalAddress.getPobox(),
|
||||
postalAddress.getNeighborhood(),
|
||||
postalAddress.getCity(),
|
||||
postalAddress.getRegion(),
|
||||
postalAddress.getPostcode(),
|
||||
postalAddress.getCountry()));
|
||||
}
|
||||
|
||||
Avatar avatar = null;
|
||||
if (contact.hasAvatar()) {
|
||||
try {
|
||||
SignalServiceAttachmentPointer attachmentPointer = AttachmentPointerUtil.createSignalAttachmentPointer(contact.getAvatar().getAvatar());
|
||||
Attachment attachment = PointerAttachment.forPointer(Optional.of(attachmentPointer.asPointer())).get();
|
||||
boolean isProfile = contact.getAvatar().getIsProfile();
|
||||
|
||||
avatar = new Avatar(null, attachment, isProfile);
|
||||
} catch (InvalidMessageStructureException e) {
|
||||
Log.w(TAG, "Unable to create avatar for contact", e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Contact(name, contact.getOrganization(), phoneNumbers, emails, postalAddresses, avatar);
|
||||
}
|
||||
|
||||
private static Phone.Type remoteToLocalType(SharedContact.Phone.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return Phone.Type.HOME;
|
||||
@@ -127,6 +185,15 @@ public class ContactModelMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private static Phone.Type remoteToLocalType(SignalServiceProtos.DataMessage.Contact.Phone.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return Phone.Type.HOME;
|
||||
case MOBILE: return Phone.Type.MOBILE;
|
||||
case WORK: return Phone.Type.WORK;
|
||||
default: return Phone.Type.CUSTOM;
|
||||
}
|
||||
}
|
||||
|
||||
private static Email.Type remoteToLocalType(SharedContact.Email.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return Email.Type.HOME;
|
||||
@@ -136,6 +203,15 @@ public class ContactModelMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private static Email.Type remoteToLocalType(SignalServiceProtos.DataMessage.Contact.Email.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return Email.Type.HOME;
|
||||
case MOBILE: return Email.Type.MOBILE;
|
||||
case WORK: return Email.Type.WORK;
|
||||
default: return Email.Type.CUSTOM;
|
||||
}
|
||||
}
|
||||
|
||||
private static PostalAddress.Type remoteToLocalType(SharedContact.PostalAddress.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return PostalAddress.Type.HOME;
|
||||
@@ -144,6 +220,14 @@ public class ContactModelMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private static PostalAddress.Type remoteToLocalType(SignalServiceProtos.DataMessage.Contact.PostalAddress.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return PostalAddress.Type.HOME;
|
||||
case WORK: return PostalAddress.Type.WORK;
|
||||
default: return PostalAddress.Type.CUSTOM;
|
||||
}
|
||||
}
|
||||
|
||||
private static SharedContact.Phone.Type localToRemoteType(Phone.Type type) {
|
||||
switch (type) {
|
||||
case HOME: return SharedContact.Phone.Type.HOME;
|
||||
|
||||
@@ -24,12 +24,13 @@ data class ConversationData(
|
||||
|
||||
data class MessageRequestData @JvmOverloads constructor(
|
||||
val isMessageRequestAccepted: Boolean,
|
||||
val isHidden: Boolean,
|
||||
private val groupsInCommon: Boolean = false,
|
||||
val isGroup: Boolean = false
|
||||
) {
|
||||
|
||||
fun includeWarningUpdateMessage(): Boolean {
|
||||
return !isMessageRequestAccepted && !groupsInCommon
|
||||
return !isMessageRequestAccepted && !groupsInCommon && !isHidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
long startTime = System.currentTimeMillis();
|
||||
int size = getSizeInternal() +
|
||||
(messageRequestData.includeWarningUpdateMessage() ? 1 : 0) +
|
||||
(messageRequestData.isHidden() ? 1 : 0) +
|
||||
(showUniversalExpireTimerUpdate ? 1 : 0);
|
||||
|
||||
Log.d(TAG, "[size(), thread " + threadId + "] " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -124,6 +125,10 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup()));
|
||||
}
|
||||
|
||||
if (messageRequestData.isHidden() && (start + length >= size())) {
|
||||
records.add(new InMemoryMessageRecord.RemovedContactHidden(threadId));
|
||||
}
|
||||
|
||||
if (showUniversalExpireTimerUpdate) {
|
||||
records.add(new InMemoryMessageRecord.UniversalExpireTimerUpdate(threadId));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user