mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-14 14:03:18 +01:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5d6889786c | ||
|
|
53d4e5c4d1 | ||
|
|
87918da943 | ||
|
|
5914a4d1cf | ||
|
|
351baa4135 | ||
|
|
1a71e1a5ae | ||
|
|
3ce68a7df8 | ||
|
|
e83c2f1e05 | ||
|
|
684e53402e | ||
|
|
db1853f775 | ||
|
|
aad835323b | ||
|
|
d6f6633c73 | ||
|
|
76984ab042 | ||
|
|
d58c4ef439 | ||
|
|
2763cfe6f4 | ||
|
|
454e9a99fc | ||
|
|
aeb250cae1 | ||
|
|
34367b4e70 | ||
|
|
451537d320 | ||
|
|
53d4825e12 | ||
|
|
24ee4a869f | ||
|
|
6ae3fb49e0 | ||
|
|
8f9713a2c0 | ||
|
|
7a2ad37333 | ||
|
|
2509d1be73 | ||
|
|
19f4073068 | ||
|
|
fd612525a1 | ||
|
|
631b428a84 | ||
|
|
09cd581cf4 | ||
|
|
fc1ea458f7 | ||
|
|
247edce7b0 | ||
|
|
57a2a32c71 | ||
|
|
d9c1ecab9b | ||
|
|
c70f1f5d75 | ||
|
|
c26cc56f20 | ||
|
|
ca21ab667a | ||
|
|
e2ae0063a5 | ||
|
|
eb150d9a15 | ||
|
|
ee48e6c347 | ||
|
|
cedf512726 | ||
|
|
2256c8591a | ||
|
|
1056adb591 | ||
|
|
53716019b6 | ||
|
|
30f6faf3d7 | ||
|
|
2a43ffad4f | ||
|
|
f9ed5c4d03 | ||
|
|
25028e0e6f | ||
|
|
1c3636eedd | ||
|
|
4d735d23b6 | ||
|
|
834d0a1cee | ||
|
|
166e555d32 | ||
|
|
7f963d7628 | ||
|
|
cebe600014 | ||
|
|
5c688289a5 | ||
|
|
bf611f3a56 | ||
|
|
150c42c590 | ||
|
|
069b707d9d | ||
|
|
8c0d979abd | ||
|
|
545f1fa5a4 | ||
|
|
49a814abef | ||
|
|
17fc0dc0a1 | ||
|
|
7c8de901f1 | ||
|
|
b5af581205 | ||
|
|
de73744432 | ||
|
|
ce3770a0fb | ||
|
|
1210b2af0f | ||
|
|
c6861f1778 | ||
|
|
906dd5cb40 | ||
|
|
97b349b0de | ||
|
|
f3b830ae20 | ||
|
|
7d7e6e5013 | ||
|
|
8ca596580c | ||
|
|
7521520b26 | ||
|
|
18554170f2 | ||
|
|
cd5a3768eb | ||
|
|
cf64f06c36 | ||
|
|
88de0f21e7 | ||
|
|
d1373d2767 | ||
|
|
baece9823b | ||
|
|
e18b2d263c | ||
|
|
d12830cb66 | ||
|
|
59141bc6a4 | ||
|
|
431e366e76 | ||
|
|
66cb2a04c3 | ||
|
|
90cc672c37 | ||
|
|
c2a76c4313 | ||
|
|
ee685936c5 | ||
|
|
a7bca89889 | ||
|
|
39f5aebbec | ||
|
|
35571e7ab2 | ||
|
|
ed2d6ea903 | ||
|
|
e1e117ce73 | ||
|
|
894095414a | ||
|
|
04baa7925f | ||
|
|
79a062c838 | ||
|
|
2cef06cd6e | ||
|
|
af4b98f424 | ||
|
|
cd66ba60e3 | ||
|
|
2d2a1049a4 | ||
|
|
03aa6a1d61 | ||
|
|
6c6d4e801f | ||
|
|
a6d7b0c7bf |
@@ -46,8 +46,8 @@ ktlint {
|
||||
version = "0.47.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1231
|
||||
def canonicalVersionName = "6.14.4"
|
||||
def canonicalVersionCode = 1237
|
||||
def canonicalVersionName = "6.16.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -68,6 +68,7 @@ def selectableVariants = [
|
||||
'playProdDebug',
|
||||
'playProdSpinner',
|
||||
'playProdPerf',
|
||||
'playProdBenchmark',
|
||||
'playProdInstrumentation',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
@@ -219,6 +220,7 @@ android {
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "false"
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
@@ -228,7 +230,7 @@ android {
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
enable !project.hasProperty('generateBaselineProfile')
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
universalApk true
|
||||
@@ -304,6 +306,17 @@ android {
|
||||
minifyEnabled true
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "true"
|
||||
}
|
||||
|
||||
benchmark {
|
||||
initWith debug
|
||||
isDefault false
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Benchmark\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "true"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +475,9 @@ dependencies {
|
||||
implementation libs.androidx.autofill
|
||||
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'
|
||||
|
||||
@@ -247,6 +247,12 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("e164 matches, e164 + aci provided") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, no pni session") {
|
||||
given(null, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
@@ -359,6 +365,18 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectSessionSwitchoverEvent(id2, E164_B)
|
||||
}
|
||||
|
||||
test("steal, e164+pni+aci & e164+aci, no pni provided, change number") {
|
||||
given(E164_A, PNI_A, ACI_A)
|
||||
given(E164_B, null, ACI_B)
|
||||
|
||||
process(E164_A, null, ACI_B)
|
||||
|
||||
expect(null, PNI_A, ACI_A)
|
||||
expect(E164_A, null, ACI_B)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
@@ -2,14 +2,18 @@ package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.app.Application
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okio.ByteString
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.KbsEnclave
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
@@ -52,7 +56,10 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
baseUrl = webServer.url("").toString()
|
||||
|
||||
addMockWebRequestHandlers(
|
||||
Get("/v1/websocket/") {
|
||||
Get("/v1/websocket/?login=") {
|
||||
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
|
||||
},
|
||||
Get("/v1/websocket", { !it.path.contains("login") }) {
|
||||
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
|
||||
}
|
||||
)
|
||||
@@ -60,9 +67,7 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
|
||||
webServer.setDispatcher(object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val handler = handlers.firstOrNull {
|
||||
request.method == it.verb && request.path.startsWith("/${it.path}")
|
||||
}
|
||||
val handler = handlers.firstOrNull { it.requestPredicate(request) }
|
||||
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
|
||||
}
|
||||
})
|
||||
@@ -106,18 +111,51 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
class MockWebSocket : WebSocketListener() {
|
||||
private val TAG = "MockWebSocket"
|
||||
|
||||
var webSocket: WebSocket? = null
|
||||
private set
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.i(TAG, "onOpen(${webSocket.hashCode()})")
|
||||
this.webSocket = webSocket
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosing(${webSocket.hashCode()}): $code, $reason")
|
||||
this.webSocket = null
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosed(${webSocket.hashCode()}): $code, $reason")
|
||||
this.webSocket = null
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "onFailure(${webSocket.hashCode()})", t)
|
||||
this.webSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var webServer: MockWebServer
|
||||
private set
|
||||
lateinit var baseUrl: String
|
||||
private set
|
||||
|
||||
val mockIdentifiedWebSocket = MockWebSocket()
|
||||
|
||||
private val handlers: MutableList<Verb> = mutableListOf()
|
||||
|
||||
fun addMockWebRequestHandlers(vararg verbs: Verb) {
|
||||
handlers.addAll(verbs)
|
||||
}
|
||||
|
||||
fun injectWebSocketMessage(value: ByteString) {
|
||||
mockIdentifiedWebSocket.webSocket!!.send(value)
|
||||
}
|
||||
|
||||
fun clearHandlers() {
|
||||
handlers.clear()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
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
|
||||
@@ -15,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
import org.thoughtcrime.securesms.testing.BobClient
|
||||
@@ -23,6 +26,10 @@ import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import android.util.Log as AndroidLog
|
||||
@@ -37,6 +44,8 @@ class MessageProcessingPerformanceTest {
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
|
||||
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
|
||||
|
||||
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
@@ -76,64 +85,43 @@ class MessageProcessingPerformanceTest {
|
||||
profileKey = ProfileKey(bob.profileKey)
|
||||
)
|
||||
|
||||
// Send message from Bob to Alice (self)
|
||||
|
||||
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
|
||||
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
// Send message from Alice to Bob
|
||||
val aliceNow = System.currentTimeMillis()
|
||||
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
|
||||
|
||||
// Build N messages from Bob to Alice
|
||||
// Send the initial messages to get past the prekey phase
|
||||
establishSession(aliceClient, bobClient, bob)
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 100
|
||||
val envelopes = ArrayList<Envelope>(messageCount)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0..messageCount) {
|
||||
envelopes += bobClient.encrypt(now)
|
||||
now += 3
|
||||
}
|
||||
|
||||
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
|
||||
val firstTimestamp = envelopes.first().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp
|
||||
|
||||
// Alice processes N messages
|
||||
|
||||
val aliceProcessLastMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
|
||||
// Inject the envelopes into the websocket
|
||||
Thread {
|
||||
for (envelope in envelopes) {
|
||||
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
|
||||
aliceClient.process(envelope, envelope.timestamp)
|
||||
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(envelope.toWebSocketPayload())
|
||||
}
|
||||
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(webSocketTombstone())
|
||||
}.start()
|
||||
|
||||
// Wait for Alice to finish processing messages
|
||||
aliceProcessLastMessageLatch.awaitFor(1.minutes)
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
.awaitFor(1.minutes)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
|
||||
// Process logs for timing data
|
||||
val entries = harness.inMemoryLogger.entries()
|
||||
|
||||
// Calculate decryption average
|
||||
val totalDecryptDuration: Long = entries
|
||||
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
|
||||
.filter { it.matches() }
|
||||
.drop(1) // Ignore the first message, which represents the prekey exchange
|
||||
.sumOf { it.group("duration")!!.toLong() }
|
||||
|
||||
val decrypts = entries
|
||||
.filter { it.tag == AliceClient.TAG }
|
||||
.drop(1)
|
||||
|
||||
val totalDecryptDuration = decrypts.sumOf { it.message!!.toLong() }
|
||||
|
||||
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / decrypts.size.toFloat()}ms")
|
||||
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
@@ -160,4 +148,62 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
|
||||
}
|
||||
|
||||
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
|
||||
// Send message from Bob to Alice (self)
|
||||
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
|
||||
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
// Send message from Alice to Bob
|
||||
val aliceNow = System.currentTimeMillis()
|
||||
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
|
||||
}
|
||||
|
||||
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0..count) {
|
||||
envelopes += bobClient.encrypt(now)
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private fun webSocketTombstone(): ByteString {
|
||||
return WebSocketMessage
|
||||
.newBuilder()
|
||||
.setRequest(
|
||||
WebSocketRequestMessage.newBuilder()
|
||||
.setVerb("PUT")
|
||||
.setPath("/api/v1/queue/empty")
|
||||
)
|
||||
.build()
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): ByteString {
|
||||
return WebSocketMessage
|
||||
.newBuilder()
|
||||
.setType(WebSocketMessage.Type.REQUEST)
|
||||
.setRequest(
|
||||
WebSocketRequestMessage.newBuilder()
|
||||
.setVerb("PUT")
|
||||
.setPath("/api/v1/message")
|
||||
.setId(Random(System.currentTimeMillis()).nextLong())
|
||||
.addHeaders("X-Signal-Timestamp: ${this.timestamp}")
|
||||
.setBody(this.toByteString())
|
||||
)
|
||||
.build()
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -35,7 +36,13 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
|
||||
|
||||
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val start = System.currentTimeMillis()
|
||||
ApplicationDependencies.getIncomingMessageObserver().processEnvelope(envelope, serverDeliveredTimestamp)
|
||||
val bufferedStore = BufferedProtocolStore.create()
|
||||
ApplicationDependencies.getIncomingMessageObserver()
|
||||
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
|
||||
?.mapNotNull { it.run() }
|
||||
?.forEach { ApplicationDependencies.getJobManager().add(it) }
|
||||
|
||||
bufferedStore.flushToDisk()
|
||||
val end = System.currentTimeMillis()
|
||||
Log.d(TAG, "${end - start}")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.util.concurrent.CountDownLatch
|
||||
@@ -13,7 +14,7 @@ typealias LogPredicate = (Entry) -> Boolean
|
||||
*/
|
||||
class InMemoryLogger : Log.Logger() {
|
||||
|
||||
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger")
|
||||
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger", ThreadUtil.PRIORITY_BACKGROUND_THREAD)
|
||||
private val predicates = mutableListOf<LogPredicate>()
|
||||
private val logEntries = mutableListOf<Entry>()
|
||||
|
||||
|
||||
@@ -7,17 +7,20 @@ import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
typealias ResponseFactory = (request: RecordedRequest) -> MockResponse
|
||||
typealias RequestPredicate = (request: RecordedRequest) -> Boolean
|
||||
|
||||
/**
|
||||
* Represent an HTTP verb for mocking web requests.
|
||||
*/
|
||||
sealed class Verb(val verb: String, val path: String, val responseFactory: ResponseFactory)
|
||||
sealed class Verb(val requestPredicate: RequestPredicate, val responseFactory: ResponseFactory)
|
||||
|
||||
class Get(path: String, responseFactory: ResponseFactory) : Verb("GET", path, responseFactory)
|
||||
class Get(path: String, predicate: RequestPredicate, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("GET", path, predicate), responseFactory) {
|
||||
constructor(path: String, responseFactory: ResponseFactory) : this(path, { true }, responseFactory)
|
||||
}
|
||||
|
||||
class Put(path: String, responseFactory: ResponseFactory) : Verb("PUT", path, responseFactory)
|
||||
class Put(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("PUT", path), responseFactory)
|
||||
|
||||
class Post(path: String, responseFactory: ResponseFactory) : Verb("POST", path, responseFactory)
|
||||
class Post(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("POST", path), responseFactory)
|
||||
|
||||
fun MockResponse.success(response: Any? = null): MockResponse {
|
||||
return setResponseCode(200).apply {
|
||||
@@ -48,3 +51,7 @@ inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
|
||||
val bodyString = String(body.readByteArray())
|
||||
return JsonUtils.fromJson(bodyString, T::class.java)
|
||||
}
|
||||
|
||||
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
|
||||
request.method == verb && request.path.startsWith("/$path") && predicate(request)
|
||||
}
|
||||
|
||||
16
app/src/benchmark/AndroidManifest.xml
Normal file
16
app/src/benchmark/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity android:name="org.signal.benchmark.BenchmarkSetupActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:exported="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.TextView
|
||||
import org.signal.benchmark.setup.TestMessages
|
||||
import org.signal.benchmark.setup.TestUsers
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
class BenchmarkSetupActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
}
|
||||
|
||||
val textView: TextView = TextView(this).apply {
|
||||
text = "done"
|
||||
}
|
||||
setContentView(textView)
|
||||
}
|
||||
|
||||
private fun setupColdStart() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupTestRecipients(50).forEach {
|
||||
val recipient: Recipient = Recipient.resolved(it)
|
||||
|
||||
TestMessages.insertIncomingTextMessage(other = recipient, body = "Cool text message?!?!")
|
||||
TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1)
|
||||
TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 2, body = "Album")
|
||||
TestMessages.insertIncomingImageMessage(other = recipient, body = "Test", attachmentCount = 1, failed = true)
|
||||
|
||||
SignalDatabase.messages.setAllMessagesRead()
|
||||
|
||||
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupConversationOpen() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupTestRecipient().let {
|
||||
val recipient: Recipient = Recipient.resolved(it)
|
||||
val messagesToAdd = 1000
|
||||
val generator: TestMessages.TimestampGenerator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
|
||||
|
||||
for (i in 0 until messagesToAdd) {
|
||||
TestMessages.insertIncomingTextMessage(other = recipient, body = "Test message $i", timestamp = generator.nextTimestamp())
|
||||
TestMessages.insertOutgoingTextMessage(other = recipient, body = "Test message $i", timestamp = generator.nextTimestamp())
|
||||
}
|
||||
|
||||
val voiceMessageId = TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = generator.nextTimestamp())
|
||||
val mmsRecord = SignalDatabase.messages.getMessageRecord(voiceMessageId) as MediaMmsMessageRecord
|
||||
TestMessages.insertOutgoingImageMessage(other = recipient, body = "test", 2, generator.nextTimestamp())
|
||||
TestMessages.insertIncomingTextMessage(other = recipient, "reply to the test message", generator.nextTimestamp())
|
||||
TestMessages.insertIncomingQuoteTextMessage(other = recipient, quote = QuoteModel(mmsRecord.timestamp, recipient.id, "Fake voice message text", false, mmsRecord.slideDeck.asAttachments(), null, QuoteModel.Type.NORMAL, null), body = "Here is a cool quote", timestamp = generator.nextTimestamp())
|
||||
TestMessages.insertOutgoingTextMessage(other = recipient, body = "longaweorijoaijwerijoiajwer", timestamp = generator.nextTimestamp())
|
||||
|
||||
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
class DummyAccountManagerFactory : AccountManagerFactory() {
|
||||
override fun createAuthenticated(context: Context, aci: ACI, pni: PNI, number: String, deviceId: Int, password: String): SignalServiceAccountManager {
|
||||
return DummyAccountManager(
|
||||
ApplicationDependencies.getSignalServiceNetworkAccess().getConfiguration(number),
|
||||
aci,
|
||||
pni,
|
||||
number,
|
||||
deviceId,
|
||||
password,
|
||||
BuildConfig.SIGNAL_AGENT,
|
||||
FeatureFlags.okHttpAutomaticRetry(),
|
||||
FeatureFlags.groupLimits().hardLimit
|
||||
)
|
||||
}
|
||||
|
||||
private class DummyAccountManager(configuration: SignalServiceConfiguration?, aci: ACI?, pni: PNI?, e164: String?, deviceId: Int, password: String?, signalAgent: String?, automaticNetworkRetry: Boolean, maxGroupSize: Int) : SignalServiceAccountManager(configuration, aci, pni, e164, deviceId, password, signalAgent, automaticNetworkRetry, maxGroupSize) {
|
||||
@Throws(IOException::class)
|
||||
override fun setGcmId(gcmRegistrationId: Optional<String>) {
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun setPreKeys(serviceIdType: ServiceIdType, identityKey: IdentityKey, signedPreKey: SignedPreKeyRecord, oneTimePreKeys: List<PreKeyRecord>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.util.Collections
|
||||
import java.util.Optional
|
||||
|
||||
object TestMessages {
|
||||
fun insertOutgoingTextMessage(other: Recipient, body: String, timestamp: Long = System.currentTimeMillis()) {
|
||||
insertOutgoingMessage(
|
||||
recipient = other,
|
||||
message = OutgoingMessage(
|
||||
recipient = other,
|
||||
body = body,
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
),
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
fun insertOutgoingImageMessage(other: Recipient, body: String? = null, attachmentCount: Int, timestamp: Long = System.currentTimeMillis()): Long {
|
||||
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
|
||||
imageAttachment()
|
||||
}
|
||||
val message = OutgoingMessage(
|
||||
recipient = other,
|
||||
body = body,
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
)
|
||||
return insertOutgoingMediaMessage(recipient = other, message = message, timestamp = timestamp)
|
||||
}
|
||||
|
||||
private fun insertOutgoingMediaMessage(recipient: Recipient, message: OutgoingMessage, timestamp: Long): Long {
|
||||
val insert = insertOutgoingMessage(recipient, message = message, timestamp = timestamp)
|
||||
setMessageMediaTransfered(insert)
|
||||
|
||||
return insert
|
||||
}
|
||||
|
||||
private fun insertOutgoingMessage(recipient: Recipient, message: OutgoingMessage, timestamp: Long? = null): Long {
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(
|
||||
message,
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(recipient),
|
||||
false,
|
||||
null
|
||||
)
|
||||
if (timestamp != null) {
|
||||
TestDbUtils.setMessageReceived(insert, timestamp)
|
||||
}
|
||||
SignalDatabase.messages.markAsSent(insert, true)
|
||||
|
||||
return insert
|
||||
}
|
||||
fun insertIncomingTextMessage(other: Recipient, body: String, timestamp: Long? = null) {
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
receivedTimeMillis = timestamp ?: System.currentTimeMillis()
|
||||
)
|
||||
|
||||
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get().messageId
|
||||
}
|
||||
fun insertIncomingQuoteTextMessage(other: Recipient, body: String, quote: QuoteModel, timestamp: Long?) {
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
quote = quote
|
||||
)
|
||||
insertIncomingMessage(other, message = message)
|
||||
}
|
||||
fun insertIncomingImageMessage(other: Recipient, body: String? = null, attachmentCount: Int, timestamp: Long? = null, failed: Boolean = false): Long {
|
||||
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
|
||||
imageAttachment()
|
||||
}
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments))
|
||||
)
|
||||
return insertIncomingMediaMessage(recipient = other, message = message, failed = failed)
|
||||
}
|
||||
|
||||
fun insertIncomingVoiceMessage(other: Recipient, timestamp: Long? = null): Long {
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(Collections.singletonList(voiceAttachment()) as List<SignalServiceAttachment>))
|
||||
)
|
||||
return insertIncomingMediaMessage(recipient = other, message = message, failed = false)
|
||||
}
|
||||
|
||||
private fun insertIncomingMediaMessage(recipient: Recipient, message: IncomingMediaMessage, failed: Boolean = false): Long {
|
||||
val id = insertIncomingMessage(recipient = recipient, message = message)
|
||||
if (failed) {
|
||||
setMessageMediaFailed(id)
|
||||
} else {
|
||||
setMessageMediaTransfered(id)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
private fun insertIncomingMessage(recipient: Recipient, message: IncomingMediaMessage): Long {
|
||||
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(recipient)).get().messageId
|
||||
}
|
||||
|
||||
private fun setMessageMediaFailed(messageId: Long) {
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(messageId).forEachIndexed { index, attachment ->
|
||||
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, messageId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMessageMediaTransfered(messageId: Long) {
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(messageId).forEachIndexed { _, attachment ->
|
||||
SignalDatabase.attachments.setTransferState(messageId, attachment.attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
}
|
||||
}
|
||||
private fun imageAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"image/webp",
|
||||
null,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
1024,
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.of("/not-there.jpg"),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun voiceAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"audio/aac",
|
||||
null,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
1024,
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.of("/not-there.aac"),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
class TimestampGenerator(private var start: Long = System.currentTimeMillis()) {
|
||||
fun nextTimestamp(): Long {
|
||||
start += 500L
|
||||
|
||||
return start
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/src/benchmark/java/org/signal/benchmark/setup/TestUsers.kt
Normal file
103
app/src/benchmark/java/org/signal/benchmark/setup/TestUsers.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import org.signal.benchmark.DummyAccountManagerFactory
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponse
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.util.UUID
|
||||
|
||||
object TestUsers {
|
||||
|
||||
private var generatedOthers: Int = 0
|
||||
|
||||
fun setupSelf(): Recipient {
|
||||
val application: Application = ApplicationDependencies.getApplication()
|
||||
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
|
||||
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
|
||||
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
|
||||
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
|
||||
preferences.edit().putBoolean("passphrase_initialized", true).commit()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = "+15555550101",
|
||||
password = Util.getSecret(18),
|
||||
registrationId = registrationRepository.registrationId,
|
||||
profileKey = registrationRepository.getProfileKey("+15555550101"),
|
||||
fcmToken = "fcm-token",
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId,
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
val verifyResponse = VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null)
|
||||
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
|
||||
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
|
||||
registrationData,
|
||||
verifyResponse,
|
||||
false
|
||||
).blockingGet()
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
|
||||
SignalStore.kbsValues().optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
fun setupTestRecipient(): RecipientId {
|
||||
return setupTestRecipients(1).first()
|
||||
}
|
||||
|
||||
fun setupTestRecipients(othersCount: Int): List<RecipientId> {
|
||||
val others = mutableListOf<RecipientId>()
|
||||
synchronized(this) {
|
||||
if (generatedOthers + othersCount !in 0 until 1000) {
|
||||
throw IllegalArgumentException("$othersCount must be between 0 and 1000")
|
||||
}
|
||||
|
||||
for (i in generatedOthers until generatedOthers + othersCount) {
|
||||
val aci = ACI.from(UUID.randomUUID())
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), otherIdentity.publicKey)
|
||||
|
||||
others += recipientId
|
||||
}
|
||||
|
||||
generatedOthers += othersCount
|
||||
}
|
||||
|
||||
return others
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import org.signal.core.util.SqlUtil.buildArgs
|
||||
|
||||
object TestDbUtils {
|
||||
|
||||
fun setMessageReceived(messageId: Long, timestamp: Long) {
|
||||
val database: SQLiteDatabase = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,11 @@
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".calls.new.NewCallActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysVisible"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".PushContactSelectionActivity"
|
||||
android:label="@string/AndroidManifest__select_contacts"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
@@ -436,6 +441,12 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".components.settings.conversation.CallInfoActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
@@ -597,12 +608,6 @@
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".ClearAvatarPromptActivity"
|
||||
android:theme="@style/Theme.AppCompat.Dialog.Alert"
|
||||
android:icon="@drawable/clear_profile_avatar"
|
||||
android:label="@string/AndroidManifest_remove_photo"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
|
||||
android:theme="@style/Theme.AppCompat.Dialog.Alert" />
|
||||
|
||||
|
||||
35489
app/src/main/baseline-prof.txt
Normal file
35489
app/src/main/baseline-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ package org.signal.glide.common.executor;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Looper;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -39,7 +41,7 @@ public class FrameDecoderExecutor {
|
||||
public Looper getLooper(int taskId) {
|
||||
int idx = taskId % sPoolNumber;
|
||||
if (idx >= mHandlerThreadGroup.size()) {
|
||||
HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx);
|
||||
HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx, ThreadUtil.PRIORITY_BACKGROUND_THREAD);
|
||||
handlerThread.start();
|
||||
|
||||
mHandlerThreadGroup.add(handlerThread);
|
||||
|
||||
@@ -11,16 +11,16 @@ object AppCapabilities {
|
||||
@JvmStatic
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
isUuid = false,
|
||||
isGv2 = true,
|
||||
isStorage = storageCapable,
|
||||
isGv1Migration = true,
|
||||
isSenderKey = true,
|
||||
isAnnouncementGroup = true,
|
||||
isChangeNumber = true,
|
||||
isStories = true,
|
||||
isGiftBadges = true,
|
||||
isPnp = FeatureFlags.phoneNumberPrivacy(),
|
||||
uuid = false,
|
||||
gv2 = true,
|
||||
storage = storageCapable,
|
||||
gv1Migration = true,
|
||||
senderKey = true,
|
||||
announcementGroup = true,
|
||||
changeNumber = true,
|
||||
stories = true,
|
||||
giftBadges = true,
|
||||
pni = FeatureFlags.phoneNumberPrivacy(),
|
||||
paymentActivation = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -492,6 +492,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
private void initializeCleanup() {
|
||||
int deleted = SignalDatabase.attachments().deleteAbandonedPreuploadedAttachments();
|
||||
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
|
||||
if (SignalStore.account().clearOldAccountDataReport()) {
|
||||
Log.i(TAG, "Deleted " + deleted + " expired account data report.");
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeGlideCodecs() {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.view.ContextThemeWrapper;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public final class ClearAvatarPromptActivity extends Activity {
|
||||
|
||||
private static final String ARG_TITLE = "arg_title";
|
||||
|
||||
public static Intent createForUserProfilePhoto() {
|
||||
Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
|
||||
intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo);
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Intent createForGroupProfilePhoto() {
|
||||
Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
|
||||
intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_group_photo);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
int message = getIntent().getIntExtra(ARG_TITLE, 0);
|
||||
|
||||
new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme))
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
|
||||
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra("delete", true);
|
||||
setResult(Activity.RESULT_OK, result);
|
||||
finish();
|
||||
})
|
||||
.setOnCancelListener(dialog -> finish())
|
||||
.show();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
|
||||
@@ -13,17 +14,19 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
class ContactSelectionListAdapter(
|
||||
context: Context,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: DisplaySmsTag,
|
||||
displaySecondaryInformation: DisplaySecondaryInformation,
|
||||
displayOptions: DisplayOptions,
|
||||
onClickCallbacks: OnContactSelectionClick,
|
||||
longClickCallbacks: LongClickCallbacks,
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks
|
||||
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks,
|
||||
callButtonClickCallbacks: CallButtonClickCallbacks
|
||||
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
|
||||
|
||||
init {
|
||||
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
|
||||
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
|
||||
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
|
||||
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
|
||||
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
|
||||
}
|
||||
|
||||
class NewGroupModel : MappingModel<NewGroupModel> {
|
||||
@@ -36,6 +39,17 @@ class ContactSelectionListAdapter(
|
||||
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
}
|
||||
|
||||
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
|
||||
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
|
||||
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
}
|
||||
|
||||
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
@@ -52,11 +66,39 @@ class ContactSelectionListAdapter(
|
||||
override fun bind(model: NewGroupModel) = Unit
|
||||
}
|
||||
|
||||
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: RefreshContactsModel) = Unit
|
||||
}
|
||||
|
||||
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
|
||||
|
||||
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
|
||||
|
||||
override fun bind(model: MoreHeaderModel) {
|
||||
headerTextView.setText(R.string.contact_selection_activity__more)
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
|
||||
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
|
||||
|
||||
override fun bind(model: EmptyModel) {
|
||||
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query)
|
||||
}
|
||||
}
|
||||
|
||||
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
|
||||
|
||||
enum class ArbitraryRow(val code: String) {
|
||||
NEW_GROUP("new-group"),
|
||||
INVITE_TO_SIGNAL("invite-to-signal");
|
||||
INVITE_TO_SIGNAL("invite-to-signal"),
|
||||
MORE_HEADING("more-heading"),
|
||||
REFRESH_CONTACTS("refresh-contacts");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) = values().first { it.code == code }
|
||||
@@ -64,7 +106,7 @@ class ContactSelectionListAdapter(
|
||||
}
|
||||
|
||||
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
|
||||
return if (query.isNullOrEmpty()) section.types.size else 0
|
||||
return section.types.size
|
||||
}
|
||||
|
||||
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
|
||||
@@ -73,10 +115,11 @@ class ContactSelectionListAdapter(
|
||||
}
|
||||
|
||||
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
|
||||
val code = ArbitraryRow.fromCode(arbitrary.type)
|
||||
return when (code) {
|
||||
return when (ArbitraryRow.fromCode(arbitrary.type)) {
|
||||
ArbitraryRow.NEW_GROUP -> NewGroupModel()
|
||||
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
|
||||
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
|
||||
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,5 +127,6 @@ class ContactSelectionListAdapter(
|
||||
interface OnContactSelectionClick : ClickCallbacks {
|
||||
fun onNewGroupClicked()
|
||||
fun onInviteToSignalClicked()
|
||||
fun onRefreshContactsClicked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
@@ -74,6 +72,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
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.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
@@ -98,8 +97,7 @@ import kotlin.Unit;
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public final class ContactSelectionListFragment extends LoggingFragment
|
||||
{
|
||||
public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
|
||||
@@ -119,41 +117,47 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
private OnContactSelectedListener onContactSelectedListener;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private View showContactsLayout;
|
||||
private Button showContactsButton;
|
||||
private TextView showContactsDescription;
|
||||
private ProgressWheel showContactsProgress;
|
||||
private String cursorFilter;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
private OnContactSelectedListener onContactSelectedListener;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private View showContactsLayout;
|
||||
private Button showContactsButton;
|
||||
private TextView showContactsDescription;
|
||||
private ProgressWheel showContactsProgress;
|
||||
private String cursorFilter;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof ListCallback) {
|
||||
listCallback = (ListCallback) context;
|
||||
if (context instanceof NewConversationCallback) {
|
||||
newConversationCallback = (NewConversationCallback) context;
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
newCallCallback = (NewCallCallback) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof ScrollCallback) {
|
||||
@@ -234,17 +238,17 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
showContactsLayout = view.findViewById(R.id.show_contacts_container);
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
showContactsLayout = view.findViewById(R.id.show_contacts_container);
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
@@ -337,9 +341,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
newCallCallback != null,
|
||||
false
|
||||
),
|
||||
this::mapStateToConfiguration,
|
||||
new ContactSearchMediator.SimpleCallbacks() {
|
||||
@Override
|
||||
@@ -348,21 +356,33 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
},
|
||||
false,
|
||||
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
|
||||
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
|
||||
context,
|
||||
fixedContacts,
|
||||
displayCheckBox,
|
||||
displaySmsTag,
|
||||
displaySecondaryInformation,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
listCallback.onNewGroup(false);
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
listCallback.onInvite();
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -386,7 +406,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
|
||||
),
|
||||
new ContactSelectionListAdapter.ArbitraryRepository()
|
||||
);
|
||||
@@ -398,6 +420,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
constraintLayout = null;
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
private @NonNull Bundle safeArguments() {
|
||||
@@ -620,6 +643,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
private class ListClickListener {
|
||||
public void onItemClick(ContactSearchKey contact) {
|
||||
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
|
||||
SelectedContact selectedContact = contact.requireSelectedContact();
|
||||
|
||||
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
||||
@@ -650,7 +674,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selected);
|
||||
}
|
||||
@@ -668,7 +692,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
});
|
||||
} else {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
|
||||
onContactSelectedListener.onBeforeContactSelected(
|
||||
isUnknown,
|
||||
Optional.ofNullable(selectedContact.getRecipientId()),
|
||||
selectedContact.getNumber(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
@@ -783,6 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
|
||||
this.onRefreshListener = onRefreshListener;
|
||||
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
|
||||
}
|
||||
|
||||
@@ -805,6 +834,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
|
||||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
|
||||
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
|
||||
@@ -813,12 +844,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return ContactSearchConfiguration.build(builder -> {
|
||||
builder.setQuery(contactSearchState.getQuery());
|
||||
|
||||
if (listCallback != null) {
|
||||
if (newConversationCallback != null) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (transportType != null) {
|
||||
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
|
||||
if (!hasQuery && includeRecents) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Recents(
|
||||
25,
|
||||
mode,
|
||||
@@ -834,13 +865,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
|
||||
includeSelf,
|
||||
transportType,
|
||||
true,
|
||||
newCallCallback == null,
|
||||
null,
|
||||
!hideLetterHeaders()
|
||||
));
|
||||
}
|
||||
|
||||
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
|
||||
if ((includeGroupsAfterContacts || hasQuery) && includeActiveGroups) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Groups(
|
||||
includeSmsContacts,
|
||||
includeV1Groups,
|
||||
@@ -853,18 +884,34 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
));
|
||||
}
|
||||
|
||||
if (listCallback != null) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
|
||||
if (hasQuery && includeGroupMembers) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.GroupMembers());
|
||||
}
|
||||
|
||||
if (includeNew) {
|
||||
builder.phone(newRowMode);
|
||||
builder.username(newRowMode);
|
||||
}
|
||||
|
||||
if (newCallCallback != null || newConversationCallback != null) {
|
||||
addMoreSection(builder);
|
||||
builder.withEmptyState(emptyBuilder -> {
|
||||
emptyBuilder.addSection(ContactSearchConfiguration.Section.Empty.INSTANCE);
|
||||
addMoreSection(emptyBuilder);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
|
||||
}
|
||||
|
||||
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
|
||||
if (includePushContacts && includeSmsContacts) {
|
||||
return ContactSearchConfiguration.TransportType.ALL;
|
||||
@@ -887,9 +934,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
|
||||
private @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
|
||||
if (isBlocked) {
|
||||
return ContactSearchConfiguration.NewRowMode.BLOCK;
|
||||
} else if (newCallCallback != null) {
|
||||
return ContactSearchConfiguration.NewRowMode.NEW_CALL;
|
||||
} else if (isActiveGroups) {
|
||||
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
|
||||
} else {
|
||||
@@ -901,11 +950,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
|
||||
private class CallButtonClickCallbacks implements ContactSearchAdapter.CallButtonClickCallbacks {
|
||||
@Override
|
||||
public void onVideoCallButtonClicked(@NonNull Recipient recipient) {
|
||||
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioCallButtonClicked(@NonNull Recipient recipient) {
|
||||
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
/**
|
||||
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||
*/
|
||||
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
|
||||
|
||||
@@ -918,12 +979,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
void onHardLimitReached(int limit);
|
||||
}
|
||||
|
||||
public interface ListCallback {
|
||||
public interface NewConversationCallback {
|
||||
void onInvite();
|
||||
|
||||
void onNewGroup(boolean forceV1);
|
||||
}
|
||||
|
||||
public interface NewCallCallback {
|
||||
void onInvite();
|
||||
}
|
||||
|
||||
public interface ScrollCallback {
|
||||
void onBeginScroll();
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
@@ -147,7 +148,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
} else {
|
||||
|
||||
@@ -69,7 +69,7 @@ import java.util.stream.Stream;
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class NewConversationActivity extends ContactSelectionActivity
|
||||
implements ContactSelectionListFragment.ListCallback, ContactSelectionListFragment.OnItemLongClickListener
|
||||
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener
|
||||
{
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@@ -102,7 +102,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
boolean smsSupported = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
|
||||
if (recipientId.isPresent()) {
|
||||
|
||||
@@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||
@@ -112,6 +113,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
|
||||
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
|
||||
private DeviceOrientationMonitor deviceOrientationMonitor;
|
||||
|
||||
@@ -312,8 +314,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
callScreen = findViewById(R.id.callScreen);
|
||||
callScreen.setControlsListener(new ControlsListener());
|
||||
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
callStateUpdatePopupWindow = new CallStateUpdatePopupWindow(callScreen);
|
||||
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
|
||||
}
|
||||
|
||||
private void initializeViewModel(boolean isLandscapeEnabled) {
|
||||
@@ -356,6 +359,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
addOnPictureInPictureModeChangedListener(info -> {
|
||||
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
|
||||
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
|
||||
callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -800,6 +804,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onMicChanged(boolean isMicEnabled) {
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(isMicEnabled ? CallStateUpdatePopupWindow.CallStateUpdate.MIC_ON
|
||||
: CallStateUpdatePopupWindow.CallStateUpdate.MIC_OFF);
|
||||
handleSetMuteAudio(!isMicEnabled);
|
||||
}
|
||||
|
||||
@@ -851,9 +857,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
|
||||
if (ringingAllowed) {
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(ringGroup ? CallStateUpdatePopupWindow.CallStateUpdate.RINGING_ON
|
||||
: CallStateUpdatePopupWindow.CallStateUpdate.RINGING_OFF);
|
||||
} else {
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
|
||||
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.RINGING_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.media.MediaRecorder;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.os.Process;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -65,6 +66,7 @@ public class AudioCodec implements Recorder {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO);
|
||||
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
|
||||
byte[] audioRecordData = new byte[bufferSize];
|
||||
ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers();
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.os.ParcelFileDescriptor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
@@ -25,7 +26,7 @@ public class AudioRecorder {
|
||||
|
||||
private static final String TAG = Log.tag(AudioRecorder.class);
|
||||
|
||||
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
|
||||
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder", ThreadUtil.PRIORITY_UI_BLOCKING_THREAD);
|
||||
|
||||
private final Context context;
|
||||
private final AudioRecordingHandler uiHandler;
|
||||
@@ -68,7 +69,8 @@ public class AudioRecorder {
|
||||
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
|
||||
try {
|
||||
if (recorder != null) {
|
||||
throw new AssertionError("We can only record once at a time.");
|
||||
recordingSingle.onError(new IllegalStateException("We can only do one recording at a time!"));
|
||||
return;
|
||||
}
|
||||
|
||||
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.media.MediaRecorder;
|
||||
import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -30,7 +31,7 @@ public class MediaRecorderWrapper implements Recorder {
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
|
||||
recorder.setOutputFile(fileDescriptor.getFileDescriptor());
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
|
||||
recorder.setAudioSamplingRate(SAMPLE_RATE);
|
||||
recorder.setAudioSamplingRate(getSampleRate());
|
||||
recorder.setAudioEncodingBitRate(BIT_RATE);
|
||||
recorder.setAudioChannels(CHANNELS);
|
||||
recorder.prepare();
|
||||
@@ -62,4 +63,12 @@ public class MediaRecorderWrapper implements Recorder {
|
||||
recorder = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int getSampleRate() {
|
||||
if ("Xiaomi".equals(Build.MANUFACTURER) && "Mi 9T".equals(Build.MODEL)) {
|
||||
// Recordings sound robotic with the standard sample rate.
|
||||
return 44000;
|
||||
}
|
||||
return SAMPLE_RATE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.res.use
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
@@ -20,10 +21,12 @@ class AvatarView @JvmOverloads constructor(
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private var storyRingScale = 0.8f
|
||||
init {
|
||||
inflate(context, R.layout.avatar_view, this)
|
||||
|
||||
isClickable = false
|
||||
storyRingScale = context.theme.obtainStyledAttributes(attrs, R.styleable.AvatarView, 0, 0).use { it.getFloat(R.styleable.AvatarView_storyRingScale, storyRingScale) }
|
||||
}
|
||||
|
||||
private val avatar: AvatarImageView = findViewById<AvatarImageView>(R.id.avatar_image_view).apply {
|
||||
@@ -40,8 +43,8 @@ class AvatarView @JvmOverloads constructor(
|
||||
storyRing.visible = true
|
||||
storyRing.isActivated = hasUnreadStory
|
||||
|
||||
avatar.scaleX = 0.8f
|
||||
avatar.scaleY = 0.8f
|
||||
avatar.scaleX = storyRingScale
|
||||
avatar.scaleY = storyRingScale
|
||||
}
|
||||
|
||||
private fun hideStoryRing() {
|
||||
|
||||
@@ -282,6 +282,8 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
{
|
||||
List<String> tablesInOrder = getTablesToExportInOrder(input);
|
||||
|
||||
Log.i(TAG, "Exporting tables in the following order: " + tablesInOrder);
|
||||
|
||||
Map<String, String> createStatementsByTable = new HashMap<>();
|
||||
|
||||
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master WHERE type = 'table' AND sql NOT NULL", null)) {
|
||||
@@ -328,12 +330,16 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
|
||||
for (String table : tables) {
|
||||
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
|
||||
}
|
||||
|
||||
|
||||
for (String table : tables) {
|
||||
Set<String> dependsOnTable = dependsOn.keySet().stream().filter(t -> dependsOn.get(t).contains(table)).collect(Collectors.toSet());
|
||||
Log.i(TAG, "Tables that depend on " + table + ": " + dependsOnTable);
|
||||
}
|
||||
|
||||
return computeTableOrder(dependsOn);
|
||||
}
|
||||
|
||||
@@ -396,6 +402,8 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
@NonNull BackupCancellationSignal cancellationSignal)
|
||||
throws IOException
|
||||
{
|
||||
Log.d(TAG, "Exporting table: " + table);
|
||||
|
||||
String template = "INSERT INTO " + table + " VALUES ";
|
||||
|
||||
try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) {
|
||||
|
||||
@@ -97,7 +97,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
|
||||
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class CallLogActionMode(
|
||||
private val callback: Callback
|
||||
) : ActionMode.Callback {
|
||||
|
||||
private var actionMode: ActionMode? = null
|
||||
private var count: Int = 0
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
mode?.title = getTitle(1)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
callback.onResetSelectionState()
|
||||
endIfActive()
|
||||
}
|
||||
|
||||
fun isInActionMode(): Boolean {
|
||||
return actionMode != null
|
||||
}
|
||||
|
||||
fun getCount(): Int {
|
||||
return if (actionMode != null) count else 0
|
||||
}
|
||||
|
||||
fun setCount(count: Int) {
|
||||
this.count = count
|
||||
actionMode?.title = getTitle(count)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
actionMode = callback.startActionMode(this)
|
||||
}
|
||||
|
||||
fun end() {
|
||||
callback.onActionModeWillEnd()
|
||||
actionMode?.finish()
|
||||
count = 0
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
private fun getTitle(callLogsSelected: Int): String {
|
||||
return callback.getResources().getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
|
||||
}
|
||||
|
||||
private fun endIfActive() {
|
||||
if (actionMode != null) {
|
||||
end()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun startActionMode(callback: ActionMode.Callback): ActionMode?
|
||||
fun onActionModeWillEnd()
|
||||
fun getResources(): Resources
|
||||
fun onResetSelectionState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
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.databinding.CallLogAdapterItemBinding
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.setRelativeDrawables
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* RecyclerView Adapter for the Call Log screen
|
||||
*/
|
||||
class CallLogAdapter(
|
||||
callbacks: Callbacks
|
||||
) : PagingMappingAdapter<CallLogRow.Id>() {
|
||||
|
||||
init {
|
||||
registerFactory(
|
||||
CallModel::class.java,
|
||||
BindingFactory(
|
||||
creator = {
|
||||
CallModelViewHolder(
|
||||
it,
|
||||
callbacks::onCallClicked,
|
||||
callbacks::onCallLongClicked,
|
||||
callbacks::onStartAudioCallClicked,
|
||||
callbacks::onStartVideoCallClicked
|
||||
)
|
||||
},
|
||||
inflater = CallLogAdapterItemBinding::inflate
|
||||
)
|
||||
)
|
||||
registerFactory(
|
||||
ClearFilterModel::class.java,
|
||||
BindingFactory(
|
||||
creator = { ClearFilterViewHolder(it, callbacks::onClearFilterClicked) },
|
||||
inflater = ConversationListItemClearFilterBinding::inflate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(
|
||||
rows: List<CallLogRow?>,
|
||||
selectionState: CallLogSelectionState,
|
||||
stagedDeletion: CallLogStagedDeletion?
|
||||
): Int {
|
||||
val filteredRows = rows
|
||||
.filterNotNull()
|
||||
.filterNot { stagedDeletion?.isStagedForDeletion(it.id) == true }
|
||||
.map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
}
|
||||
}
|
||||
|
||||
submitList(filteredRows)
|
||||
|
||||
return filteredRows.size
|
||||
}
|
||||
|
||||
private class CallModel(
|
||||
val call: CallLogRow.Call,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallModel> {
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallModel): Boolean = call.id == newItem.call.id
|
||||
override fun areContentsTheSame(newItem: CallModel): Boolean {
|
||||
return call == newItem.call &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallModel): Any? {
|
||||
return if (call == newItem.call && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectionStateTheSame(newItem: CallModel): Boolean {
|
||||
return selectionState.contains(call.id) == newItem.selectionState.contains(newItem.call.id) &&
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
}
|
||||
|
||||
private class ClearFilterModel : MappingModel<ClearFilterModel> {
|
||||
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
|
||||
private val onStartAudioCallClicked: (Recipient) -> Unit,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
onCallClicked(model.call)
|
||||
}
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onCallLongClicked(itemView, model.call)
|
||||
}
|
||||
|
||||
itemView.isSelected = model.selectionState.contains(model.call.id)
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.call.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
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)
|
||||
}
|
||||
|
||||
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
|
||||
binding.callInfo.text = context.getString(
|
||||
R.string.CallLogAdapter__s_dot_s,
|
||||
context.getString(getCallStateStringRes(event, direction)),
|
||||
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(
|
||||
start = getCallStateDrawableRes(event, direction)
|
||||
)
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
}
|
||||
)
|
||||
|
||||
TextViewCompat.setCompoundDrawableTintList(
|
||||
binding.callInfo,
|
||||
ColorStateList.valueOf(color)
|
||||
)
|
||||
|
||||
binding.callInfo.setTextColor(color)
|
||||
}
|
||||
|
||||
private fun presentCallType(callType: CallTable.Type, peer: Recipient) {
|
||||
when (callType) {
|
||||
CallTable.Type.AUDIO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_phone_24)
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(peer) }
|
||||
}
|
||||
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(peer) }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@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 class ClearFilterViewHolder(
|
||||
binding: ConversationListItemClearFilterBinding,
|
||||
onClearFilterClicked: () -> Unit
|
||||
) : BindingViewHolder<ClearFilterModel, ConversationListItemClearFilterBinding>(binding) {
|
||||
|
||||
init {
|
||||
binding.clearFilter.setOnClickListener { onClearFilterClicked() }
|
||||
}
|
||||
|
||||
override fun bind(model: ClearFilterModel) = Unit
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
/**
|
||||
* Invoked when a call row is clicked
|
||||
*/
|
||||
fun onCallClicked(callLogRow: CallLogRow.Call)
|
||||
|
||||
/**
|
||||
* Invoked when a call row is long-clicked
|
||||
*/
|
||||
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when the clear filter button is pressed
|
||||
*/
|
||||
fun onClearFilterClicked()
|
||||
|
||||
/**
|
||||
* Invoked when user presses the audio icon
|
||||
*/
|
||||
fun onStartAudioCallClicked(peer: Recipient)
|
||||
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(peer: Recipient)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Context menu for row items on the Call Log screen.
|
||||
*/
|
||||
class CallLogContextMenu(
|
||||
private val fragment: Fragment,
|
||||
private val callbacks: Callbacks
|
||||
) {
|
||||
fun show(anchor: View, call: CallLogRow.Call) {
|
||||
anchor.isSelected = true
|
||||
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.onDismiss { anchor.isSelected = false }
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(call),
|
||||
getAudioCallActionItem(call),
|
||||
getGoToChatActionItem(call),
|
||||
getInfoActionItem(call),
|
||||
getSelectActionItem(call),
|
||||
getDeleteActionItem(call)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
|
||||
// TODO [alex] -- Need group calling disposition to make this correct
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_video_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__video_call)
|
||||
) {
|
||||
CommunicationActions.startVideoCall(fragment, call.peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.peer.isGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_phone_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__audio_call)
|
||||
) {
|
||||
CommunicationActions.startVoiceCall(fragment, call.peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId))
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__select)
|
||||
) {
|
||||
callbacks.startSelection(call)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.call.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_trash_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__delete)
|
||||
) {
|
||||
callbacks.deleteCall(call)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow.Call)
|
||||
fun deleteCall(call: CallLogRow.Call)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
/**
|
||||
* Allows user to only display certain classes of calls.
|
||||
*/
|
||||
enum class CallLogFilter {
|
||||
/**
|
||||
* All call logs will be displayed
|
||||
*/
|
||||
ALL,
|
||||
|
||||
/**
|
||||
* Only missed calls will be displayed
|
||||
*/
|
||||
MISSED
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Resources
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
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.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnFilterStateChanged
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
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.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Call Log tab.
|
||||
*/
|
||||
@SuppressLint("DiscouragedApi")
|
||||
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
|
||||
|
||||
companion object {
|
||||
private const val LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD = 25
|
||||
}
|
||||
|
||||
private val viewModel: CallLogViewModel by viewModels()
|
||||
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val callLogContextMenu = CallLogContextMenu(this, this)
|
||||
private val callLogActionMode = CallLogActionMode(CallLogActionModeCallback())
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private val menuProvider = object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.calls_tab_menu, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val isFiltered = viewModel.filterSnapshot == CallLogFilter.MISSED
|
||||
menu.findItem(R.id.action_clear_missed_call_filter).isVisible = isFiltered
|
||||
menu.findItem(R.id.action_filter_missed_calls).isVisible = !isFiltered
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_settings -> startActivity(AppSettingsActivity.home(requireContext()))
|
||||
R.id.action_notification_profile -> NotificationProfileSelectionFragment.show(parentFragmentManager)
|
||||
R.id.action_filter_missed_calls -> filterMissedCalls()
|
||||
R.id.action_clear_missed_call_filter -> onClearFilterClicked()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
|
||||
val adapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
adapter.setPagingController(viewModel.controller)
|
||||
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
val filteredCount = adapter.submitCallRows(data, selected.first, selected.second)
|
||||
binding.emptyState.visible = filteredCount == 0
|
||||
}
|
||||
|
||||
disposables += Flowables.combineLatest(viewModel.selectedAndStagedDeletion, viewModel.totalCount)
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (selected, totalCount) ->
|
||||
if (selected.first.isNotEmpty(totalCount)) {
|
||||
callLogActionMode.setCount(selected.first.count(totalCount))
|
||||
} else {
|
||||
callLogActionMode.end()
|
||||
}
|
||||
}
|
||||
|
||||
binding.recycler.adapter = adapter
|
||||
|
||||
requireListener<Material3OnScrollHelperBinder>().bindScrollHelper(binding.recycler)
|
||||
binding.fab.setOnClickListener {
|
||||
startActivity(NewCallActivity.createIntent(requireContext()))
|
||||
}
|
||||
|
||||
binding.pullView.setPillText(R.string.CallLogFragment__filtered_by_missed)
|
||||
|
||||
binding.bottomActionBar.setItems(
|
||||
listOf(
|
||||
ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = getString(R.string.CallLogFragment__select_all)
|
||||
) {
|
||||
viewModel.selectAll()
|
||||
},
|
||||
ActionItem(
|
||||
iconRes = R.drawable.symbol_trash_24,
|
||||
title = getString(R.string.CallLogFragment__delete),
|
||||
action = this::handleDeleteSelectedRows
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
initializePullToFilter()
|
||||
initializeTapToScrollToTop()
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!closeSearchIfOpen()) {
|
||||
tabsViewModel.onChatsSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
signalBottomActionBarController = SignalBottomActionBarController(
|
||||
binding.bottomActionBar,
|
||||
binding.recycler,
|
||||
BottomActionBarControllerCallback()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
initializeSearchAction()
|
||||
}
|
||||
|
||||
private fun initializeTapToScrollToTop() {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleDeleteSelectedRows() {
|
||||
val count = callLogActionMode.getCount()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
viewModel.stageSelectionDeletion()
|
||||
callLogActionMode.end()
|
||||
Snackbar
|
||||
.make(
|
||||
binding.root,
|
||||
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, count, count),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.addCallback(SnackbarDeletionCallback())
|
||||
.setAction(R.string.CallLogFragment__undo) {
|
||||
viewModel.cancelStagedDeletion()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun initializeSearchAction() {
|
||||
val searchBinder = requireListener<SearchBinder>()
|
||||
searchBinder.getSearchAction().setOnClickListener {
|
||||
searchBinder.onSearchOpened()
|
||||
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
|
||||
|
||||
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
|
||||
override fun onSearchTextChange(text: String) {
|
||||
viewModel.setSearchQuery(text.trim())
|
||||
}
|
||||
|
||||
override fun onSearchClosed() {
|
||||
viewModel.setSearchQuery("")
|
||||
searchBinder.onSearchClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializePullToFilter() {
|
||||
val collapsingToolbarLayout = binding.collapsingToolbar
|
||||
val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt()
|
||||
|
||||
binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource ->
|
||||
when (state) {
|
||||
FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL)
|
||||
FilterPullState.OPENING -> {
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
|
||||
viewModel.setFilter(CallLogFilter.MISSED)
|
||||
}
|
||||
|
||||
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
|
||||
// TODO[alex] -- hint here? SignalStore.uiHints().incrementNeverDisplayPullToFilterTip()
|
||||
}
|
||||
|
||||
FilterPullState.CLOSE_APEX -> ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
binding.pullView.onCloseClicked = OnCloseClicked {
|
||||
onClearFilterClicked()
|
||||
}
|
||||
|
||||
val conversationFilterBehavior = Objects.requireNonNull<ConversationFilterBehavior?>((binding.recyclerCoordinatorAppBar.layoutParams as CoordinatorLayout.LayoutParams).behavior as ConversationFilterBehavior?)
|
||||
conversationFilterBehavior.callback = object : ConversationFilterBehavior.Callback {
|
||||
override fun onStopNestedScroll() {
|
||||
binding.pullView.onUserDragFinished()
|
||||
}
|
||||
|
||||
override fun canStartNestedScroll(): Boolean {
|
||||
return !callLogActionMode.isInActionMode() || !isSearchOpen() || binding.pullView.isCloseable()
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerCoordinatorAppBar.addOnOffsetChangedListener { layout: AppBarLayout, verticalOffset: Int ->
|
||||
val progress = 1 - verticalOffset.toFloat() / -layout.height
|
||||
binding.pullView.onUserDrag(progress)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
|
||||
callLogContextMenu.show(itemView, callLogRow)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearFilterClicked() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun onStartAudioCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, peer)
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(peer: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, peer)
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow.Call) {
|
||||
callLogActionMode.start()
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
override fun deleteCall(call: CallLogRow.Call) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
|
||||
viewModel.stageCallDeletion(call)
|
||||
Snackbar
|
||||
.make(
|
||||
binding.root,
|
||||
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, 1, 1),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.addCallback(SnackbarDeletionCallback())
|
||||
.setAction(R.string.CallLogFragment__undo) {
|
||||
viewModel.cancelStagedDeletion()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun filterMissedCalls() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
private fun isSearchOpen(): Boolean {
|
||||
return isSearchVisible() || viewModel.hasSearchQuery
|
||||
}
|
||||
|
||||
private fun closeSearchIfOpen(): Boolean {
|
||||
if (isSearchOpen()) {
|
||||
requireListener<SearchBinder>().getSearchToolbar().get().collapse()
|
||||
requireListener<SearchBinder>().onSearchClosed()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isSearchVisible(): Boolean {
|
||||
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
|
||||
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE
|
||||
}
|
||||
|
||||
private inner class BottomActionBarControllerCallback : SignalBottomActionBarController.Callback {
|
||||
override fun onBottomActionBarVisibilityChanged(visibility: Int) = Unit
|
||||
}
|
||||
|
||||
private inner class CallLogActionModeCallback : CallLogActionMode.Callback {
|
||||
override fun startActionMode(callback: ActionMode.Callback): ActionMode? {
|
||||
val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
|
||||
requireListener<Callback>().onMultiSelectStarted()
|
||||
signalBottomActionBarController.setVisibility(true)
|
||||
return actionMode
|
||||
}
|
||||
|
||||
override fun onActionModeWillEnd() {
|
||||
requireListener<Callback>().onMultiSelectFinished()
|
||||
signalBottomActionBarController.setVisibility(false)
|
||||
}
|
||||
|
||||
override fun getResources(): Resources = resources
|
||||
override fun onResetSelectionState() {
|
||||
viewModel.clearSelected()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SnackbarDeletionCallback : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
viewModel.commitStagedDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMultiSelectStarted()
|
||||
fun onMultiSelectFinished()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.signal.paging.PagedDataSource
|
||||
|
||||
class CallLogPagedDataSource(
|
||||
private val query: String?,
|
||||
private val filter: CallLogFilter,
|
||||
private val repository: CallRepository
|
||||
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
|
||||
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
|
||||
var callsCount = 0
|
||||
|
||||
override fun size(): Int {
|
||||
callsCount = repository.getCallsCount(query, filter)
|
||||
return callsCount + (if (hasFilter) 1 else 0)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val calls: MutableList<CallLogRow> = repository.getCalls(query, filter, start, length).toMutableList()
|
||||
|
||||
if (calls.size < length && hasFilter) {
|
||||
calls.add(CallLogRow.ClearFilter)
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
|
||||
|
||||
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
|
||||
|
||||
interface CallRepository {
|
||||
fun getCallsCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
|
||||
return SignalDatabase.calls.getCallsCount(query, filter)
|
||||
}
|
||||
|
||||
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
emitter.onNext(Unit)
|
||||
}
|
||||
|
||||
val databaseObserver = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
val messageObserver = DatabaseObserver.MessageObserver {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
||||
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(databaseObserver)
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedCallLogs(
|
||||
selectedMessageIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.messages.deleteCallUpdates(selectedMessageIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteAllCallLogsExcept(
|
||||
selectedMessageIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* A row to be displayed in the call log
|
||||
*/
|
||||
sealed class CallLogRow {
|
||||
|
||||
abstract val id: Id
|
||||
|
||||
/**
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
data class Call(
|
||||
val call: CallTable.Call,
|
||||
val peer: Recipient,
|
||||
val date: Long,
|
||||
override val id: Id = Id.Call(call.messageId)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
* A row which can be used to clear the current filter.
|
||||
*/
|
||||
object ClearFilter : CallLogRow() {
|
||||
override val id: Id = Id.ClearFilter
|
||||
}
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val messageId: Long) : Id()
|
||||
object ClearFilter : Id()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
/**
|
||||
* Selection state object for call logs.
|
||||
*/
|
||||
sealed class CallLogSelectionState {
|
||||
abstract fun contains(callId: CallLogRow.Id): Boolean
|
||||
abstract fun isNotEmpty(totalCount: Int): Boolean
|
||||
|
||||
abstract fun count(totalCount: Int): Int
|
||||
|
||||
abstract fun selected(): Set<CallLogRow.Id>
|
||||
fun isExclusionary(): Boolean = this is Excludes
|
||||
|
||||
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
|
||||
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
|
||||
|
||||
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return if (contains(callId)) {
|
||||
deselect(callId)
|
||||
} else {
|
||||
select(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes contains an opt-in list of call logs.
|
||||
*/
|
||||
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean {
|
||||
return includes.contains(callId)
|
||||
}
|
||||
|
||||
override fun isNotEmpty(totalCount: Int): Boolean {
|
||||
return includes.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun count(totalCount: Int): Int {
|
||||
return includes.size
|
||||
}
|
||||
|
||||
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Includes(includes + callId)
|
||||
}
|
||||
|
||||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Includes(includes - callId)
|
||||
}
|
||||
|
||||
override fun selected(): Set<CallLogRow.Id> {
|
||||
return includes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes contains an opt-out list of call logs.
|
||||
*/
|
||||
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
|
||||
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
|
||||
|
||||
override fun count(totalCount: Int): Int {
|
||||
return totalCount - excluded.size
|
||||
}
|
||||
|
||||
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Excludes(excluded - callId)
|
||||
}
|
||||
|
||||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Excludes(excluded + callId)
|
||||
}
|
||||
|
||||
override fun selected(): Set<CallLogRow.Id> = excluded
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun empty(): CallLogSelectionState = Includes(emptySet())
|
||||
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
|
||||
/**
|
||||
* Encapsulates a single deletion action
|
||||
*/
|
||||
class CallLogStagedDeletion(
|
||||
private val stateSnapshot: CallLogSelectionState,
|
||||
private val repository: CallLogRepository
|
||||
) {
|
||||
|
||||
private var isCommitted = false
|
||||
|
||||
fun isStagedForDeletion(id: CallLogRow.Id): Boolean {
|
||||
return stateSnapshot.contains(id)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun cancel() {
|
||||
isCommitted = true
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun commit() {
|
||||
if (isCommitted) {
|
||||
return
|
||||
}
|
||||
|
||||
isCommitted = true
|
||||
val messageIds = stateSnapshot.selected()
|
||||
.filterIsInstance<CallLogRow.Id.Call>()
|
||||
.map { it.messageId }
|
||||
.toSet()
|
||||
|
||||
if (stateSnapshot.isExclusionary()) {
|
||||
repository.deleteAllCallLogsExcept(messageIds).subscribe()
|
||||
} else {
|
||||
repository.deleteSelectedCallLogs(messageIds).subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import org.signal.paging.ObservablePagedData
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
/**
|
||||
* ViewModel for call log management.
|
||||
*/
|
||||
class CallLogViewModel(
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository()
|
||||
) : ViewModel() {
|
||||
private val callLogStore = RxStore(CallLogState())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val pagedData: BehaviorProcessor<ObservablePagedData<CallLogRow.Id, CallLogRow>> = BehaviorProcessor.create()
|
||||
|
||||
private val distinctQueryFilterPairs = callLogStore
|
||||
.stateFlowable
|
||||
.map { (query, filter) -> Pair(query, filter) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val controller = ProxyPagingController<CallLogRow.Id>()
|
||||
val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
|
||||
val selectedAndStagedDeletion: Flowable<Pair<CallLogSelectionState, CallLogStagedDeletion?>> = callLogStore
|
||||
.stateFlowable
|
||||
.map { it.selectionState to it.stagedDeletion }
|
||||
|
||||
val totalCount: Flowable<Int> = Flowable.combineLatest(distinctQueryFilterPairs, data) { a, _ -> a }
|
||||
.map { (query, filter) -> callLogRepository.getCallsCount(query, filter) }
|
||||
|
||||
val selectionStateSnapshot: CallLogSelectionState
|
||||
get() = callLogStore.state.selectionState
|
||||
val filterSnapshot: CallLogFilter
|
||||
get() = callLogStore.state.filter
|
||||
|
||||
val hasSearchQuery: Boolean
|
||||
get() = !callLogStore.state.query.isNullOrBlank()
|
||||
|
||||
private val pagingConfig = PagingConfig.Builder()
|
||||
.setBufferPages(1)
|
||||
.setPageSize(20)
|
||||
.setStartIndex(0)
|
||||
.build()
|
||||
|
||||
init {
|
||||
disposables.add(callLogStore)
|
||||
disposables += distinctQueryFilterPairs.subscribe { (query, filter) ->
|
||||
pagedData.onNext(
|
||||
PagedData.createForObservable(
|
||||
CallLogPagedDataSource(query, filter, callLogRepository),
|
||||
pagingConfig
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
disposables += pagedData.map { it.controller }.subscribe {
|
||||
controller.set(it)
|
||||
}
|
||||
|
||||
disposables += callLogRepository.listenForChanges().subscribe {
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
commitStagedDeletion()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
callLogStore.update {
|
||||
val selectionState = CallLogSelectionState.selectAll()
|
||||
it.copy(selectionState = selectionState)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelected(callId: CallLogRow.Id) {
|
||||
callLogStore.update {
|
||||
val selectionState = it.selectionState.toggle(callId)
|
||||
it.copy(selectionState = selectionState)
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stageCallDeletion(call: CallLogRow.Call) {
|
||||
callLogStore.state.stagedDeletion?.commit()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
stagedDeletion = CallLogStagedDeletion(
|
||||
CallLogSelectionState.empty().toggle(call.id),
|
||||
callLogRepository
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stageSelectionDeletion() {
|
||||
callLogStore.state.stagedDeletion?.commit()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
stagedDeletion = CallLogStagedDeletion(
|
||||
it.selectionState,
|
||||
callLogRepository
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitStagedDeletion() {
|
||||
callLogStore.state.stagedDeletion?.commit()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
stagedDeletion = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelStagedDeletion() {
|
||||
callLogStore.state.stagedDeletion?.cancel()
|
||||
callLogStore.update {
|
||||
it.copy(
|
||||
stagedDeletion = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelected() {
|
||||
callLogStore.update {
|
||||
it.copy(selectionState = CallLogSelectionState.empty())
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
callLogStore.update { it.copy(query = query) }
|
||||
}
|
||||
|
||||
fun setFilter(filter: CallLogFilter) {
|
||||
callLogStore.update { it.copy(filter = filter) }
|
||||
}
|
||||
|
||||
private data class CallLogState(
|
||||
val query: String? = null,
|
||||
val filter: CallLogFilter = CallLogFilter.ALL,
|
||||
val selectionState: CallLogSelectionState = CallLogSelectionState.empty(),
|
||||
val stagedDeletion: CallLogStagedDeletion? = null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.thoughtcrime.securesms.calls.new
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.ContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.InviteActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery.refresh
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment.NewCallCallback {
|
||||
|
||||
override fun onCreate(icicle: Bundle?, ready: Boolean) {
|
||||
super.onCreate(icicle, ready)
|
||||
requireNotNull(supportActionBar)
|
||||
supportActionBar?.setTitle(R.string.NewCallActivity__new_call)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
addMenuProvider(NewCallMenuProvider())
|
||||
}
|
||||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
|
||||
if (isFromUnknownSearchKey) {
|
||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.")
|
||||
if (SignalStore.account().isRegistered) {
|
||||
Log.i(TAG, "[onContactSelected] Doing contact refresh.")
|
||||
val progress = SimpleProgressDialog.show(this)
|
||||
SimpleTask.run<Recipient>(lifecycle, {
|
||||
var resolved = Recipient.external(this, number!!)
|
||||
if (!resolved.isRegistered || !resolved.hasServiceId()) {
|
||||
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.")
|
||||
resolved = try {
|
||||
refresh(this, resolved, false)
|
||||
Recipient.resolved(resolved.id)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.")
|
||||
return@run null
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}) { resolved: Recipient? ->
|
||||
progress.dismiss()
|
||||
if (resolved != null) {
|
||||
if (resolved.isRegistered && resolved.hasServiceId()) {
|
||||
launch(resolved)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, resolved.getDisplayName(this)))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
callback.accept(true)
|
||||
}
|
||||
|
||||
private fun launch(recipient: Recipient) {
|
||||
if (recipient.isGroup) {
|
||||
CommunicationActions.startVideoCall(this, recipient)
|
||||
} else {
|
||||
CommunicationActions.startVoiceCall(this, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(NewCallActivity::class.java)
|
||||
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, NewCallActivity::class.java)
|
||||
.putExtra(
|
||||
ContactSelectionListFragment.DISPLAY_MODE,
|
||||
ContactSelectionDisplayMode.none()
|
||||
.withPush()
|
||||
.withActiveGroups()
|
||||
.withGroupMembers()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInvite() {
|
||||
startActivity(Intent(this, InviteActivity::class.java))
|
||||
}
|
||||
|
||||
private inner class NewCallMenuProvider : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.new_call_menu, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
android.R.id.home -> ActivityCompat.finishAfterTransition(this@NewCallActivity)
|
||||
R.id.menu_refresh -> onRefresh()
|
||||
R.id.menu_invite -> startActivity(Intent(this@NewCallActivity, InviteActivity::class.java))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,10 @@ public class AnimatingToggle extends FrameLayout {
|
||||
public void displayQuick(@Nullable View view) {
|
||||
if (view == current && current.getVisibility() == View.VISIBLE) return;
|
||||
if (current != null) current.setVisibility(View.GONE);
|
||||
if (view != null) view.setVisibility(View.VISIBLE);
|
||||
|
||||
if (view != null) {
|
||||
view.setVisibility(View.VISIBLE);
|
||||
view.clearAnimation();
|
||||
}
|
||||
current = view;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Annotation;
|
||||
@@ -15,11 +14,7 @@ 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.text.style.StrikethroughSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
@@ -42,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;
|
||||
@@ -63,26 +59,19 @@ import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
public class ComposeText extends EmojiEditText {
|
||||
|
||||
private static final char EMOJI_STARTER = ':';
|
||||
private static final long EMOJI_KEYWORD_DELAY = 1500;
|
||||
|
||||
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
|
||||
|
||||
private final Runnable keywordSearchRunnable = () -> {
|
||||
Editable text = getText();
|
||||
if (text != null && enoughToFilter(text, true)) {
|
||||
performFiltering(text, true);
|
||||
}
|
||||
};
|
||||
|
||||
public ComposeText(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
@@ -164,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);
|
||||
}
|
||||
@@ -310,6 +302,12 @@ 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() {
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
@@ -326,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;
|
||||
}
|
||||
|
||||
@@ -340,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;
|
||||
}
|
||||
|
||||
@@ -349,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();
|
||||
@@ -359,10 +362,12 @@ 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) {
|
||||
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
|
||||
replacement.setSpan(style, 0, charSequence.length(), MessageStyler.SPAN_FLAGS);
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
@@ -532,6 +537,11 @@ public class ComposeText extends EmojiEditText {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldPersistSignalStylingWhenPasting() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if we think the user may be inputting a time.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.text.Annotation
|
||||
import android.text.Editable
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.TextWatcher
|
||||
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.
|
||||
*
|
||||
* This watcher observes changes to the text and will shrink supported style ranges as necessary
|
||||
* to provide the desired behavior.
|
||||
*/
|
||||
class ComposeTextStyleWatcher : TextWatcher {
|
||||
private val markerAnnotation = Annotation("text-formatting", "marker")
|
||||
private var textSnapshotPriorToChange: CharSequence? = null
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
|
||||
if (s is Spannable) {
|
||||
s.removeSpan(markerAnnotation)
|
||||
}
|
||||
|
||||
textSnapshotPriorToChange = s.subSequence(start, start + count)
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (s is Spannable) {
|
||||
s.removeSpan(markerAnnotation)
|
||||
|
||||
if (count > 0) {
|
||||
s.setSpan(markerAnnotation, start, start + count, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
val editStart = s.getSpanStart(markerAnnotation)
|
||||
val editEnd = s.getSpanEnd(markerAnnotation)
|
||||
|
||||
s.removeSpan(markerAnnotation)
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class NumericKeyboardView @JvmOverloads constructor(
|
||||
var listener: Listener? = null
|
||||
|
||||
init {
|
||||
layoutDirection = LAYOUT_DIRECTION_LTR
|
||||
inflate(context, R.layout.numeric_keyboard_view, this)
|
||||
|
||||
findViewById<TextView>(R.id.numeric_keyboard_1).setOnClickListener {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.InputFilter;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -14,7 +18,9 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
@@ -35,9 +41,9 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
a.recycle();
|
||||
|
||||
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
|
||||
@@ -57,8 +63,8 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
}
|
||||
|
||||
public void insertEmoji(String emoji) {
|
||||
final int start = getSelectionStart();
|
||||
final int end = getSelectionEnd();
|
||||
final int start = getSelectionStart();
|
||||
final int end = getSelectionEnd();
|
||||
|
||||
getText().replace(Math.min(start, end), Math.max(start, end), emoji);
|
||||
setSelection(start + emoji.length());
|
||||
@@ -66,8 +72,11 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
|
||||
@Override
|
||||
public void invalidateDrawable(@NonNull Drawable drawable) {
|
||||
if (drawable instanceof EmojiDrawable) invalidate();
|
||||
else super.invalidateDrawable(drawable);
|
||||
if (drawable instanceof EmojiDrawable) {
|
||||
invalidate();
|
||||
} else {
|
||||
super.invalidateDrawable(drawable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -95,4 +104,50 @@ public class EmojiEditText extends AppCompatEditText {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTextContextMenuItem(int id) {
|
||||
if (id == android.R.id.paste) {
|
||||
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
|
||||
|
||||
if (clipData != null) {
|
||||
CharSequence label = clipData.getDescription().getLabel();
|
||||
CharSequence pendingPaste = getTextFromClipData(clipData);
|
||||
|
||||
if (TextUtils.equals(Util.COPY_LABEL, label) && shouldPersistSignalStylingWhenPasting()) {
|
||||
return super.onTextContextMenuItem(id);
|
||||
} else if (Build.VERSION.SDK_INT >= 23) {
|
||||
return super.onTextContextMenuItem(android.R.id.pasteAsPlainText);
|
||||
} else if (pendingPaste != null) {
|
||||
Util.copyToClipboard(getContext(), pendingPaste.toString());
|
||||
return super.onTextContextMenuItem(id);
|
||||
}
|
||||
}
|
||||
} else if (id == android.R.id.copy || id == android.R.id.cut) {
|
||||
boolean originalResult = super.onTextContextMenuItem(id);
|
||||
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(getContext());
|
||||
CharSequence clipText = getTextFromClipData(clipboardManager.getPrimaryClip());
|
||||
|
||||
if (clipText != null) {
|
||||
Util.copyToClipboard(getContext(), clipText);
|
||||
return true;
|
||||
}
|
||||
|
||||
return originalResult;
|
||||
}
|
||||
|
||||
return super.onTextContextMenuItem(id);
|
||||
}
|
||||
|
||||
private @Nullable CharSequence getTextFromClipData(@Nullable ClipData data) {
|
||||
if (data != null && data.getItemCount() > 0) {
|
||||
return data.getItemAt(0).coerceToText(getContext());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean shouldPersistSignalStylingWhenPasting() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ public final class EmojiStrings {
|
||||
public static final String STICKER = "\u2B50";
|
||||
public static final String GIFT = "\uD83C\uDF81";
|
||||
public static final String CARD = "\uD83D\uDCB3";
|
||||
public static final String FAILED_STORY = "\u2757";
|
||||
}
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
@@ -11,7 +10,10 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
|
||||
/**
|
||||
* The DSL API can be completely replaced by compose.
|
||||
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API"
|
||||
*/
|
||||
open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
|
||||
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.annotation.CallSuper
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.models.AsyncSwitch
|
||||
@@ -205,12 +205,19 @@ class MultiSelectListPreferenceViewHolder(itemView: View) : PreferenceViewHolder
|
||||
|
||||
class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPreference>(itemView) {
|
||||
|
||||
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
|
||||
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
|
||||
|
||||
override fun bind(model: SwitchPreference) {
|
||||
super.bind(model)
|
||||
switchWidget.setOnCheckedChangeListener(null)
|
||||
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
switchWidget.isChecked = model.isChecked
|
||||
|
||||
switchWidget.setOnCheckedChangeListener { _, _ ->
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.annotation.StringRes
|
||||
@@ -21,7 +20,10 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import java.lang.UnsupportedOperationException
|
||||
|
||||
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
|
||||
/**
|
||||
* The DSL API can be completely replaced by compose.
|
||||
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API
|
||||
*/
|
||||
abstract class DSLSettingsFragment(
|
||||
@StringRes private val titleId: Int = -1,
|
||||
@MenuRes private val menuId: Int = -1,
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
|
||||
import org.thoughtcrime.securesms.lock.v2.KbsConstants
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -121,6 +122,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
}
|
||||
)
|
||||
|
||||
if (FeatureFlags.exportAccountData()) {
|
||||
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,264 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
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.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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 com.google.android.material.snackbar.Snackbar
|
||||
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.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class ExportAccountDataFragment : ComposeFragment() {
|
||||
private val viewModel: ExportAccountDataViewModel by viewModels()
|
||||
|
||||
private fun deleteReport() {
|
||||
viewModel.deleteReport()
|
||||
Snackbar.make(requireView(), R.string.ExportAccountDataFragment__delete_report_snackbar, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun exportReport() {
|
||||
val report = viewModel.onGenerateReport()
|
||||
ShareCompat.IntentBuilder(requireContext())
|
||||
.setStream(report.uri)
|
||||
.setType(report.mimeType)
|
||||
.startChooser()
|
||||
}
|
||||
|
||||
private fun dismissExportDialog() {
|
||||
viewModel.dismissExportConfirmationDialog()
|
||||
}
|
||||
|
||||
private fun dismissDeleteDialog() {
|
||||
viewModel.dismissDeleteConfirmationDialog()
|
||||
}
|
||||
|
||||
private fun dismissDownloadErrorDialog() {
|
||||
viewModel.dismissDownloadErrorDialog()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
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 {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__export_explanation, stringResource(id = R.string.ExportAccountDataFragment__learn_more)),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, start = 32.dp, end = 32.dp, bottom = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
if (state.reportDownloaded) {
|
||||
ExportReportOptions(exportAsJson = state.exportAsJson)
|
||||
} else {
|
||||
DownloadReportOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.downloadInProgress) {
|
||||
DownloadProgressDialog()
|
||||
} else if (state.showDownloadFailedDialog) {
|
||||
DownloadFailedDialog()
|
||||
} else if (state.showExportDialog) {
|
||||
ExportReportConfirmationDialog()
|
||||
} else if (state.showDeleteDialog) {
|
||||
DeleteReportConfirmationDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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__report_download_failed),
|
||||
dismiss = stringResource(id = R.string.ExportAccountDataFragment__ok_action),
|
||||
onDismiss = this::dismissDownloadErrorDialog
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteReportConfirmationDialog() {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.ExportAccountDataFragment__delete_report_confirmation),
|
||||
body = stringResource(R.string.ExportAccountDataFragment__delete_report_confirmation_message),
|
||||
confirm = stringResource(R.string.ExportAccountDataFragment__delete_report_action),
|
||||
dismiss = stringResource(R.string.ExportAccountDataFragment__cancel_action),
|
||||
onConfirm = this::deleteReport,
|
||||
onDismiss = this::dismissDeleteDialog,
|
||||
confirmColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
@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 DownloadReportOptions() {
|
||||
Buttons.LargeTonal(
|
||||
onClick = viewModel::onDownloadReport,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 52.dp, start = 32.dp, end = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ExportAccountDataFragment__download_report),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = viewModel::showDeleteConfirmationDialog,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 14.dp, start = 32.dp, end = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ExportAccountDataFragment__delete_report),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ExportAccountDataFragment__report_deletion_disclaimer, stringResource(id = R.string.ExportAccountDataFragment__learn_more)),
|
||||
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,59 @@
|
||||
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.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
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(): Completable {
|
||||
return Completable.create {
|
||||
try {
|
||||
SignalStore.account().setAccountDataReport(accountManager.accountDataReport, System.currentTimeMillis())
|
||||
it.onComplete()
|
||||
} catch (e: IOException) {
|
||||
it.onError(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun generateAccountDataReport(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(SignalStore.account().accountDataReport)
|
||||
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)
|
||||
.createForSingleSessionInMemory()
|
||||
|
||||
return ExportedReport(mimeType = mimeType, uri = uri)
|
||||
}
|
||||
|
||||
data class ExportedReport(val mimeType: String, val uri: Uri)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.export
|
||||
|
||||
data class ExportAccountDataState(
|
||||
val reportDownloaded: Boolean,
|
||||
val downloadInProgress: Boolean,
|
||||
val exportAsJson: Boolean,
|
||||
val showDownloadFailedDialog: Boolean = false,
|
||||
val showDeleteDialog: Boolean = false,
|
||||
val showExportDialog: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
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.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
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(reportDownloaded = false, downloadInProgress = false, exportAsJson = false)
|
||||
)
|
||||
|
||||
val state: State<ExportAccountDataState> = _state
|
||||
|
||||
init {
|
||||
_state.value = _state.value.copy(reportDownloaded = SignalStore.account().hasAccountDataReport())
|
||||
}
|
||||
|
||||
fun onGenerateReport(): ExportAccountDataRepository.ExportedReport = repository.generateAccountDataReport(state.value.exportAsJson)
|
||||
fun onDownloadReport() {
|
||||
_state.value = _state.value.copy(downloadInProgress = true)
|
||||
disposables += repository.downloadAccountDataReport()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
_state.value = _state.value.copy(downloadInProgress = false, reportDownloaded = true)
|
||||
}, { throwable ->
|
||||
Log.e(TAG, throwable)
|
||||
_state.value = _state.value.copy(downloadInProgress = false, showDownloadFailedDialog = true)
|
||||
})
|
||||
}
|
||||
|
||||
fun setExportAsJson() {
|
||||
_state.value = _state.value.copy(exportAsJson = true)
|
||||
}
|
||||
|
||||
fun setExportAsTxt() {
|
||||
_state.value = _state.value.copy(exportAsJson = false)
|
||||
}
|
||||
|
||||
fun showDeleteConfirmationDialog() {
|
||||
_state.value = _state.value.copy(showDeleteDialog = true)
|
||||
}
|
||||
|
||||
fun dismissDeleteConfirmationDialog() {
|
||||
_state.value = _state.value.copy(showDeleteDialog = 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)
|
||||
}
|
||||
|
||||
fun deleteReport() {
|
||||
SignalStore.account().deleteAccountDataReport()
|
||||
_state.value = _state.value.copy(reportDownloaded = false)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class SmsSettingsRepository(
|
||||
|
||||
@WorkerThread
|
||||
private fun checkInsecureMessageCount(): SmsExportState? {
|
||||
val totalSmsMmsCount = smsDatabase.insecureMessageCount + mmsDatabase.insecureMessageCount
|
||||
val totalSmsMmsCount = smsDatabase.getInsecureMessageCount() + mmsDatabase.getInsecureMessageCount()
|
||||
|
||||
return if (totalSmsMmsCount == 0) {
|
||||
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
|
||||
@@ -29,7 +29,7 @@ class SmsSettingsRepository(
|
||||
|
||||
@WorkerThread
|
||||
private fun checkUnexportedInsecureMessageCount(): SmsExportState {
|
||||
val totalUnexportedCount = smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount
|
||||
val totalUnexportedCount = smsDatabase.getUnexportedInsecureMessagesCount() + mmsDatabase.getUnexportedInsecureMessagesCount()
|
||||
|
||||
return if (totalUnexportedCount > 0) {
|
||||
SmsExportState.HAS_UNEXPORTED_MESSAGES
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
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
|
||||
@@ -69,7 +69,7 @@ class EditNotificationProfileScheduleFragment : LoggingFragment(R.layout.fragmen
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
val enableToggle: SwitchMaterial = view.findViewById(R.id.edit_notification_profile_schedule_switch)
|
||||
val enableToggle: MaterialSwitch = view.findViewById(R.id.edit_notification_profile_schedule_switch)
|
||||
enableToggle.setOnClickListener { viewModel.setEnabled(enableToggle.isChecked) }
|
||||
|
||||
val startTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_start_time)
|
||||
|
||||
@@ -113,7 +113,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
|
||||
return mode or ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.select(recipientId.get())
|
||||
callback.accept(true)
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profile
|
||||
|
||||
import android.view.View
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
@@ -34,15 +34,17 @@ object NotificationProfilePreference {
|
||||
|
||||
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
|
||||
|
||||
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
|
||||
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
switchWidget.setOnCheckedChangeListener(null)
|
||||
switchWidget.visible = model.showSwitch
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
switchWidget.isChecked = model.isOn
|
||||
iconView.background.colorFilter = SimpleColorFilter(model.color.colorInt())
|
||||
switchWidget.setOnCheckedChangeListener { _, _ -> model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieDrawable
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -31,7 +31,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var switch: SwitchMaterial
|
||||
private lateinit var switch: MaterialSwitch
|
||||
private lateinit var heading: TextView
|
||||
|
||||
private lateinit var badgeRepository: BadgeRepository
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.Callback {
|
||||
|
||||
override val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
}
|
||||
@@ -13,11 +13,12 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
|
||||
open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
|
||||
|
||||
override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme()
|
||||
|
||||
@@ -66,7 +67,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
|
||||
|
||||
@JvmStatic
|
||||
fun forGroup(context: Context, groupId: GroupId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId))
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId), null)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
@@ -76,7 +77,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
|
||||
|
||||
@JvmStatic
|
||||
fun forRecipient(context: Context, recipientId: RecipientId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null)
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null, null)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
@@ -84,6 +85,21 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
|
||||
.putExtra(ARG_START_BUNDLE, startBundle)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun forCall(context: Context, callPeer: Recipient, callMessageIds: LongArray): Intent {
|
||||
val startBundleBuilder = if (callPeer.isGroup) {
|
||||
ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(callPeer.requireGroupId()), callMessageIds)
|
||||
} else {
|
||||
ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds)
|
||||
}
|
||||
|
||||
val startBundle = startBundleBuilder.build().toBundle()
|
||||
|
||||
return getIntent(context)
|
||||
.setClass(context, CallInfoActivity::class.java)
|
||||
.putExtra(ARG_START_BUNDLE, startBundle)
|
||||
}
|
||||
|
||||
private fun getIntent(context: Context): Intent {
|
||||
return Intent(context, ConversationSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.conversation_settings)
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
@@ -49,6 +50,7 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
|
||||
@@ -83,6 +85,7 @@ import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
|
||||
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
|
||||
@@ -92,6 +95,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
|
||||
import java.util.Locale
|
||||
|
||||
private const val REQUEST_CODE_VIEW_CONTACT = 1
|
||||
private const val REQUEST_CODE_ADD_CONTACT = 2
|
||||
@@ -103,6 +107,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
|
||||
private val args: ConversationSettingsFragmentArgs by navArgs()
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
private val blockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply {
|
||||
@@ -122,12 +127,12 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
private val viewModel by viewModels<ConversationSettingsViewModel>(
|
||||
factoryProducer = {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as? ParcelableGroupId
|
||||
|
||||
ConversationSettingsViewModel.Factory(
|
||||
recipientId = args.recipientId,
|
||||
groupId = ParcelableGroupId.get(groupId),
|
||||
callMessageIds = args.callMessageIds ?: longArrayOf(),
|
||||
repository = ConversationSettingsRepository(requireContext())
|
||||
)
|
||||
}
|
||||
@@ -180,6 +185,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
progress.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia()
|
||||
REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient()
|
||||
REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient()
|
||||
@@ -219,6 +225,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
InternalPreference.register(adapter)
|
||||
GroupDescriptionPreference.register(adapter)
|
||||
LegacyGroupPreference.register(adapter)
|
||||
CallPreference.register(adapter)
|
||||
|
||||
val recipientId = args.recipientId
|
||||
if (recipientId != null) {
|
||||
@@ -376,6 +383,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
ButtonStripPreference.Model(
|
||||
state = state.buttonStripState,
|
||||
onMessageClick = {
|
||||
val intent = ConversationIntents
|
||||
.createBuilder(requireContext(), state.recipient.id, state.threadId)
|
||||
.build()
|
||||
|
||||
startActivity(intent)
|
||||
},
|
||||
onAddToStoryClick = {
|
||||
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
@@ -428,6 +442,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (state.calls.isNotEmpty()) {
|
||||
val firstCall = state.calls.first()
|
||||
sectionHeaderPref(DSLSettingsText.from(DateUtils.formatDate(Locale.getDefault(), firstCall.record.timestamp)))
|
||||
|
||||
for (call in state.calls) {
|
||||
customPref(call)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan))
|
||||
val icon = if (state.disappearingMessagesLifespan <= 0 || state.recipient.isBlocked) {
|
||||
R.drawable.ic_update_timer_disabled_16
|
||||
@@ -491,6 +516,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ContactLinkState.ADD -> {
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
@@ -505,6 +531,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ContactLinkState.NONE -> {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
@@ -37,6 +39,16 @@ class ConversationSettingsRepository(
|
||||
private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context)
|
||||
) {
|
||||
|
||||
fun getCallEvents(callMessageIds: LongArray): Single<List<MessageRecord>> {
|
||||
return if (callMessageIds.isEmpty()) {
|
||||
Single.just(emptyList())
|
||||
} else {
|
||||
Single.fromCallable {
|
||||
SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence().toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getThreadMedia(threadId: Long): Optional<Cursor> {
|
||||
return if (threadId <= 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
@@ -19,6 +20,7 @@ data class ConversationSettingsState(
|
||||
val sharedMedia: Cursor? = null,
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
val displayInternalRecipientDetails: Boolean = false,
|
||||
val calls: List<CallPreference.Model> = emptyList(),
|
||||
private val sharedMediaLoaded: Boolean = false,
|
||||
private val specificSettingsState: SpecificSettingsState
|
||||
) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
@@ -33,6 +34,7 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Optional
|
||||
|
||||
sealed class ConversationSettingsViewModel(
|
||||
private val callMessageIds: LongArray,
|
||||
private val repository: ConversationSettingsRepository,
|
||||
specificSettingsState: SpecificSettingsState
|
||||
) : ViewModel() {
|
||||
@@ -64,6 +66,10 @@ sealed class ConversationSettingsViewModel(
|
||||
repository.getThreadMedia(tId)
|
||||
}
|
||||
|
||||
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
|
||||
state.copy(calls = callRecords.map { CallPreference.Model(it) })
|
||||
}
|
||||
|
||||
store.update(sharedMedia) { cursor, state ->
|
||||
if (!cleared) {
|
||||
if (cursor.isPresent) {
|
||||
@@ -128,8 +134,10 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
private class RecipientSettingsViewModel(
|
||||
private val recipientId: RecipientId,
|
||||
private val callMessageIds: LongArray,
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ConversationSettingsViewModel(
|
||||
callMessageIds,
|
||||
repository,
|
||||
SpecificSettingsState.RecipientSettingsState()
|
||||
) {
|
||||
@@ -151,12 +159,13 @@ sealed class ConversationSettingsViewModel(
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isMessageAvailable = callMessageIds.isNotEmpty(),
|
||||
isVideoAvailable = recipient.registered == RecipientTable.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
|
||||
isAudioAvailable = isAudioAvailable,
|
||||
isAudioSecure = recipient.registered == RecipientTable.RegisteredState.REGISTERED,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = !recipient.isSelf,
|
||||
isSearchAvailable = true
|
||||
isSearchAvailable = callMessageIds.isEmpty()
|
||||
),
|
||||
disappearingMessagesLifespan = recipient.expiresInSeconds,
|
||||
canModifyBlockedState = !recipient.isSelf && RecipientUtil.isBlockable(recipient),
|
||||
@@ -256,8 +265,9 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
private class GroupSettingsViewModel(
|
||||
private val groupId: GroupId,
|
||||
private val callMessageIds: LongArray,
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ConversationSettingsViewModel(repository, SpecificSettingsState.GroupSettingsState(groupId)) {
|
||||
) : ConversationSettingsViewModel(callMessageIds, repository, SpecificSettingsState.GroupSettingsState(groupId)) {
|
||||
|
||||
private val liveGroup = LiveGroup(groupId)
|
||||
|
||||
@@ -271,12 +281,13 @@ sealed class ConversationSettingsViewModel(
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isMessageAvailable = callMessageIds.isNotEmpty(),
|
||||
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
|
||||
isAudioAvailable = false,
|
||||
isAudioSecure = recipient.isPushV2Group,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = true,
|
||||
isSearchAvailable = true,
|
||||
isSearchAvailable = callMessageIds.isEmpty(),
|
||||
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.storyValues().isFeatureDisabled
|
||||
),
|
||||
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
|
||||
@@ -479,6 +490,7 @@ sealed class ConversationSettingsViewModel(
|
||||
class Factory(
|
||||
private val recipientId: RecipientId? = null,
|
||||
private val groupId: GroupId? = null,
|
||||
private val callMessageIds: LongArray,
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -486,8 +498,8 @@ sealed class ConversationSettingsViewModel(
|
||||
return requireNotNull(
|
||||
modelClass.cast(
|
||||
when {
|
||||
recipientId != null -> RecipientSettingsViewModel(recipientId, repository)
|
||||
groupId != null -> GroupSettingsViewModel(groupId, repository)
|
||||
recipientId != null -> RecipientSettingsViewModel(recipientId, callMessageIds, repository)
|
||||
groupId != null -> GroupSettingsViewModel(groupId, callMessageIds, repository)
|
||||
else -> error("One of RecipientId or GroupId required.")
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Renders a single call preference row when displaying call info.
|
||||
*/
|
||||
object CallPreference {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, ConversationSettingsCallPreferenceItemBinding::inflate))
|
||||
}
|
||||
|
||||
class Model(
|
||||
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 &&
|
||||
record.isOutgoing == newItem.record.isOutgoing &&
|
||||
record.timestamp == newItem.record.timestamp &&
|
||||
record.id == newItem.record.id
|
||||
}
|
||||
}
|
||||
|
||||
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.callTime.text = getCallTime(model.record)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getCallIcon(messageRecord: MessageRecord): Int {
|
||||
return when (messageRecord.type) {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallType(messageRecord: MessageRecord): String {
|
||||
val id = when (messageRecord.type) {
|
||||
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}")
|
||||
}
|
||||
|
||||
return context.getString(id)
|
||||
}
|
||||
|
||||
private fun getCallTime(messageRecord: MessageRecord): String {
|
||||
return DateUtils.getOnlyTimeString(context, Locale.getDefault(), messageRecord.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,10 @@ fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
|
||||
return configuration
|
||||
}
|
||||
|
||||
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
|
||||
/**
|
||||
* The DSL API can be completely replaced by compose.
|
||||
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API
|
||||
*/
|
||||
class DSLConfiguration {
|
||||
private val children = arrayListOf<MappingModel<*>>()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ViewSwitcher
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
@@ -33,23 +33,32 @@ object AsyncSwitch {
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
|
||||
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
|
||||
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
|
||||
private val switcher: ViewSwitcher = itemView.findViewById(R.id.switcher)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
super.bind(model)
|
||||
switchWidget.setOnCheckedChangeListener(null)
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
switchWidget.isChecked = model.isChecked
|
||||
itemView.isEnabled = !model.isProcessing && model.isEnabled
|
||||
switcher.displayedChild = if (model.isProcessing) 1 else 0
|
||||
|
||||
itemView.setOnClickListener {
|
||||
fun onClick() {
|
||||
if (!model.isProcessing) {
|
||||
itemView.isEnabled = false
|
||||
switcher.displayedChild = 1
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onClick()
|
||||
}
|
||||
|
||||
switchWidget.setOnCheckedChangeListener { _, _ ->
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
@@ -35,7 +35,7 @@ object OutlinedSwitch {
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val text: TextView = findViewById(R.id.outlined_switch_control_text)
|
||||
private val switch: SwitchMaterial = findViewById(R.id.outlined_switch_switch)
|
||||
private val switch: MaterialSwitch = findViewById(R.id.outlined_switch_switch)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
text.text = model.text.resolve(context)
|
||||
|
||||
@@ -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,148 @@
|
||||
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.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorInt
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Drawable that animates a sparkle effect for spoilers.
|
||||
*/
|
||||
class SpoilerDrawable(@ColorInt color: Int) : Drawable() {
|
||||
|
||||
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
|
||||
private val paints = listOf(Paint(), Paint(), Paint())
|
||||
private var lastDrawTime: Long = 0
|
||||
|
||||
private var particleCount = 60
|
||||
|
||||
private var allParticles = Array(3) { Array(particleCount) { Particle(random) } }
|
||||
private var allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
|
||||
|
||||
init {
|
||||
for (paint in paints) {
|
||||
paint.strokeCap = Paint.Cap.ROUND
|
||||
paint.strokeWidth = DimensionUnit.DP.toPixels(1.5f)
|
||||
}
|
||||
|
||||
alpha = 255
|
||||
colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
val pixelArea = (bounds.right - bounds.left) * (bounds.bottom - bounds.top)
|
||||
|
||||
val newParticleCount = (pixelArea.toFloat() * PARTICLES_PER_PIXEL).toInt()
|
||||
if (newParticleCount != particleCount) {
|
||||
if (newParticleCount > allParticles[0].size) {
|
||||
allParticles = Array(3) { i ->
|
||||
Array(newParticleCount) { particleIndex ->
|
||||
allParticles[i].getOrNull(particleIndex) ?: Particle(random)
|
||||
}
|
||||
}
|
||||
|
||||
allPoints = Array(3) { i ->
|
||||
FloatArray(newParticleCount * 2) { pointIndex ->
|
||||
allPoints[i].getOrNull(pointIndex) ?: 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
particleCount = newParticleCount
|
||||
}
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val left = bounds.left
|
||||
val top = bounds.top
|
||||
val right = bounds.right
|
||||
val bottom = bounds.bottom
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val dt = now - lastDrawTime
|
||||
lastDrawTime = now
|
||||
|
||||
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 || !bounds.contains(particle.x.toInt(), particle.y.toInt())) {
|
||||
particle.x = (random.nextFloat() * (right - left)) + left
|
||||
particle.y = (random.nextFloat() * (bottom - top)) + 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, paints[0])
|
||||
canvas.drawPoints(allPoints[1], 0, particleCount * 2, paints[1])
|
||||
canvas.drawPoints(allPoints[2], 0, particleCount * 2, paints[2])
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
paints.forEachIndexed { index, paint ->
|
||||
paint.alpha = (alpha * alphaStrength[index]).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
for (paint in paints) {
|
||||
paint.colorFilter = colorFilter
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSPARENT", "android.graphics.PixelFormat"))
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSPARENT
|
||||
}
|
||||
|
||||
data class Particle(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var xVel: Float,
|
||||
var yVel: Float,
|
||||
var timeRemaining: Float
|
||||
) {
|
||||
constructor(random: Random) : this(
|
||||
-1f,
|
||||
-1f,
|
||||
if (random.nextFloat() < 0.5f) 1f else -1f,
|
||||
if (random.nextFloat() < 0.5f) 1f else -1f,
|
||||
500 + 1000 * random.nextFloat()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
|
||||
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.text.Layout
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 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,
|
||||
spoilerDrawables: List<SpoilerDrawable>
|
||||
)
|
||||
|
||||
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 : 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,
|
||||
spoilerDrawables: List<SpoilerDrawable>
|
||||
) {
|
||||
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)
|
||||
|
||||
spoilerDrawables[0].setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawables[0].draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
class MultiLineSpoilerRenderer : 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,
|
||||
spoilerDrawables: List<SpoilerDrawable>
|
||||
) {
|
||||
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, spoilerDrawables)
|
||||
|
||||
if (startLine + 1 < endLine) {
|
||||
var left = Int.MAX_VALUE
|
||||
var right = -1
|
||||
lineTop = Int.MAX_VALUE
|
||||
lineBottom = -1
|
||||
for (line in startLine + 1 until endLine) {
|
||||
left = min(left, layout.getLineLeft(line).toInt())
|
||||
right = max(right, layout.getLineRight(line).toInt())
|
||||
|
||||
lineTop = min(lineTop, lineTopCache.get(line, layout) { getLineTop(layout, line) })
|
||||
lineBottom = max(lineBottom, lineBottomCache.get(line, layout) { getLineBottom(layout, line) })
|
||||
}
|
||||
spoilerDrawables[1].setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawables[1].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, spoilerDrawables)
|
||||
}
|
||||
|
||||
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
|
||||
if (start > end) {
|
||||
spoilerDrawables[2].setBounds(end, top, start, bottom)
|
||||
spoilerDrawables[2].draw(canvas)
|
||||
} else {
|
||||
spoilerDrawables[0].setBounds(start, top, end, bottom)
|
||||
spoilerDrawables[0].draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
|
||||
if (start > end) {
|
||||
spoilerDrawables[0].setBounds(end, top, start, bottom)
|
||||
spoilerDrawables[0].draw(canvas)
|
||||
} else {
|
||||
spoilerDrawables[2].setBounds(start, top, end, bottom)
|
||||
spoilerDrawables[2].draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.text.Annotation
|
||||
import android.text.Layout
|
||||
import android.text.Spanned
|
||||
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 var animatorRunning = false
|
||||
private var textColor: Int
|
||||
|
||||
private var spoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
|
||||
private var nextSpoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
|
||||
|
||||
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 { view.invalidate() }
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.REVERSE
|
||||
}
|
||||
|
||||
init {
|
||||
single = SingleLineSpoilerRenderer()
|
||||
multi = MultiLineSpoilerRenderer()
|
||||
textColor = view.textColors.defaultColor
|
||||
}
|
||||
|
||||
fun updateFromTextColor() {
|
||||
val color = view.textColors.defaultColor
|
||||
if (color != textColor) {
|
||||
spoilerDrawablePool
|
||||
.values
|
||||
.flatten()
|
||||
.forEach { it.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) }
|
||||
textColor = color
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
|
||||
var hasSpoilersToRender = false
|
||||
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
|
||||
|
||||
nextSpoilerDrawablePool.clear()
|
||||
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
|
||||
val drawables: List<SpoilerDrawable> = spoilerDrawablePool[annotation] ?: listOf(SpoilerDrawable(textColor), SpoilerDrawable(textColor), SpoilerDrawable(textColor))
|
||||
|
||||
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, drawables)
|
||||
nextSpoilerDrawablePool[annotation] = drawables
|
||||
hasSpoilersToRender = true
|
||||
}
|
||||
|
||||
val temporaryPool = spoilerDrawablePool
|
||||
spoilerDrawablePool = nextSpoilerDrawablePool
|
||||
nextSpoilerDrawablePool = temporaryPool
|
||||
|
||||
if (hasSpoilersToRender) {
|
||||
if (!animatorRunning) {
|
||||
animator.start()
|
||||
animatorRunning = true
|
||||
}
|
||||
} else {
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
@@ -41,7 +40,7 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
|
||||
public CallParticipantsListUpdatePopupWindow(@NonNull ViewGroup parent) {
|
||||
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_participant_list_update, parent, false),
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewUtil.dpToPx(94));
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
this.parent = parent;
|
||||
this.avatarImageView = getContentView().findViewById(R.id.avatar);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Popup window which is displayed whenever the call state changes from user input.
|
||||
*/
|
||||
class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.call_state_update, parent, false),
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
) {
|
||||
|
||||
private var enabled: Boolean = true
|
||||
private var pendingUpdate: CallStateUpdate? = null
|
||||
private var lastUpdate: CallStateUpdate? = null
|
||||
private val dismissDebouncer = Debouncer(2, TimeUnit.SECONDS)
|
||||
private val iconView = contentView.findViewById<ImageView>(R.id.icon)
|
||||
private val descriptionView = contentView.findViewById<TextView>(R.id.description)
|
||||
|
||||
init {
|
||||
setOnDismissListener {
|
||||
val pending = pendingUpdate
|
||||
if (pending != null) {
|
||||
onCallStateUpdate(pending)
|
||||
}
|
||||
}
|
||||
|
||||
animationStyle = R.style.CallStateToastAnimation
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
dismissDebouncer.clear()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun onCallStateUpdate(callStateUpdate: CallStateUpdate) {
|
||||
if (isShowing && lastUpdate == callStateUpdate) {
|
||||
dismissDebouncer.publish { dismiss() }
|
||||
} else if (isShowing) {
|
||||
dismissDebouncer.clear()
|
||||
pendingUpdate = callStateUpdate
|
||||
dismiss()
|
||||
} else {
|
||||
pendingUpdate = null
|
||||
lastUpdate = callStateUpdate
|
||||
presentCallState(callStateUpdate)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentCallState(callStateUpdate: CallStateUpdate) {
|
||||
if (callStateUpdate.iconRes == null) {
|
||||
iconView.setImageDrawable(null)
|
||||
} else {
|
||||
iconView.setImageResource(callStateUpdate.iconRes)
|
||||
}
|
||||
|
||||
descriptionView.setText(callStateUpdate.stringRes)
|
||||
}
|
||||
|
||||
private fun show() {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
showAtLocation(parent, Gravity.TOP or Gravity.START, 0, 0)
|
||||
measureChild()
|
||||
update()
|
||||
dismissDebouncer.publish { dismiss() }
|
||||
}
|
||||
|
||||
private fun measureChild() {
|
||||
contentView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
}
|
||||
|
||||
enum class CallStateUpdate(
|
||||
@DrawableRes val iconRes: Int?,
|
||||
@StringRes val stringRes: Int
|
||||
) {
|
||||
RINGING_ON(R.drawable.symbol_bell_ring_compact_16, R.string.CallStateUpdatePopupWindow__ringing_on),
|
||||
RINGING_OFF(R.drawable.symbol_bell_slash_compact_16, R.string.CallStateUpdatePopupWindow__ringing_off),
|
||||
RINGING_DISABLED(null, R.string.CallStateUpdatePopupWindow__group_is_too_large),
|
||||
MIC_ON(R.drawable.symbol_mic_compact_16, R.string.CallStateUpdatePopupWindow__mic_on),
|
||||
MIC_OFF(R.drawable.symbol_mic_slash_compact_16, R.string.CallStateUpdatePopupWindow__mic_off)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,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.ic_speaker_solid_24),
|
||||
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_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);
|
||||
|
||||
private final @StringRes int labelRes;
|
||||
private final @DrawableRes int iconRes;
|
||||
|
||||
@@ -76,11 +76,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
public static final int CONTROLS_HEIGHT = 98;
|
||||
|
||||
private WebRtcAudioOutputToggleButton audioToggle;
|
||||
private TextView audioToggleLabel;
|
||||
private AccessibleToggleButton videoToggle;
|
||||
private TextView videoToggleLabel;
|
||||
private AccessibleToggleButton micToggle;
|
||||
private TextView micToggleLabel;
|
||||
private ViewGroup smallLocalRenderFrame;
|
||||
private CallParticipantView smallLocalRender;
|
||||
private View largeLocalRenderFrame;
|
||||
@@ -95,15 +92,12 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private ControlsListener controlsListener;
|
||||
private RecipientId recipientId;
|
||||
private ImageView answer;
|
||||
private TextView answerWithoutVideoLabel;
|
||||
private ImageView cameraDirectionToggle;
|
||||
private TextView cameraDirectionToggleLabel;
|
||||
private AccessibleToggleButton ringToggle;
|
||||
private TextView ringToggleLabel;
|
||||
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
|
||||
private ImageView hangup;
|
||||
private TextView hangupLabel;
|
||||
private View answerWithoutVideo;
|
||||
private View answerWithoutVideoLabel;
|
||||
private View topGradient;
|
||||
private View footerGradient;
|
||||
private View startCallControls;
|
||||
@@ -166,11 +160,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
super.onFinishInflate();
|
||||
|
||||
audioToggle = findViewById(R.id.call_screen_speaker_toggle);
|
||||
audioToggleLabel = findViewById(R.id.call_screen_speaker_toggle_label);
|
||||
videoToggle = findViewById(R.id.call_screen_video_toggle);
|
||||
videoToggleLabel = findViewById(R.id.call_screen_video_toggle_label);
|
||||
micToggle = findViewById(R.id.call_screen_audio_mic_toggle);
|
||||
micToggleLabel = findViewById(R.id.call_screen_audio_mic_toggle_label);
|
||||
smallLocalRenderFrame = findViewById(R.id.call_screen_pip);
|
||||
smallLocalRender = findViewById(R.id.call_screen_small_local_renderer);
|
||||
largeLocalRenderFrame = findViewById(R.id.call_screen_large_local_renderer_frame);
|
||||
@@ -183,14 +174,11 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
parent = findViewById(R.id.call_screen);
|
||||
participantsParent = findViewById(R.id.call_screen_participants_parent);
|
||||
answer = findViewById(R.id.call_screen_answer_call);
|
||||
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
|
||||
cameraDirectionToggleLabel = findViewById(R.id.call_screen_camera_direction_toggle_label);
|
||||
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
|
||||
ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label);
|
||||
hangup = findViewById(R.id.call_screen_end_call);
|
||||
hangupLabel = findViewById(R.id.call_screen_end_call_label);
|
||||
answerWithoutVideo = findViewById(R.id.call_screen_answer_without_video);
|
||||
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
|
||||
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
|
||||
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
|
||||
hangup = findViewById(R.id.call_screen_end_call);
|
||||
answerWithoutVideo = findViewById(R.id.call_screen_answer_without_video);
|
||||
topGradient = findViewById(R.id.call_screen_header_gradient);
|
||||
footerGradient = findViewById(R.id.call_screen_footer_gradient);
|
||||
startCallControls = findViewById(R.id.call_screen_start_call_controls);
|
||||
@@ -650,7 +638,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
if (webRtcControls.displayAudioToggle()) {
|
||||
visibleViewSet.add(audioToggle);
|
||||
visibleViewSet.add(audioToggleLabel);
|
||||
|
||||
audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(),
|
||||
webRtcControls.enableHeadsetInAudioToggle());
|
||||
@@ -660,23 +647,19 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
if (webRtcControls.displayCameraToggle()) {
|
||||
visibleViewSet.add(cameraDirectionToggle);
|
||||
visibleViewSet.add(cameraDirectionToggleLabel);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayEndCall()) {
|
||||
visibleViewSet.add(hangup);
|
||||
visibleViewSet.add(hangupLabel);
|
||||
visibleViewSet.add(footerGradient);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayMuteAudio()) {
|
||||
visibleViewSet.add(micToggle);
|
||||
visibleViewSet.add(micToggleLabel);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayVideoToggle()) {
|
||||
visibleViewSet.add(videoToggle);
|
||||
visibleViewSet.add(videoToggleLabel);
|
||||
}
|
||||
|
||||
if (webRtcControls.displaySmallOngoingCallButtons()) {
|
||||
@@ -701,9 +684,9 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
if (webRtcControls.displayRingToggle()) {
|
||||
visibleViewSet.add(ringToggle);
|
||||
visibleViewSet.add(ringToggleLabel);
|
||||
}
|
||||
|
||||
|
||||
if (webRtcControls.isFadeOutEnabled()) {
|
||||
if (!controls.isFadeOutEnabled()) {
|
||||
scheduleFadeOut();
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -30,7 +31,9 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
SheetContent()
|
||||
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
|
||||
SheetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class ContactSelectionDisplayMode {
|
||||
public static final int FLAG_PUSH = 1;
|
||||
public static final int FLAG_SMS = 1 << 1;
|
||||
@@ -11,5 +13,50 @@ public final class ContactSelectionDisplayMode {
|
||||
public static final int FLAG_HIDE_NEW = 1 << 6;
|
||||
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
|
||||
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
|
||||
|
||||
public static final int FLAG_GROUP_MEMBERS = 1 << 9;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
|
||||
|
||||
public static Builder all() {
|
||||
return new Builder(FLAG_ALL);
|
||||
}
|
||||
|
||||
public static Builder none() {
|
||||
return new Builder(0);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
int displayMode = 0;
|
||||
|
||||
public Builder(int displayMode) {
|
||||
this.displayMode = displayMode;
|
||||
}
|
||||
|
||||
public @NonNull Builder withPush() {
|
||||
displayMode = setFlag(displayMode, FLAG_PUSH);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withActiveGroups() {
|
||||
displayMode = setFlag(displayMode, FLAG_ACTIVE_GROUPS);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withGroupMembers() {
|
||||
displayMode = setFlag(displayMode, FLAG_GROUP_MEMBERS);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int build() {
|
||||
return displayMode;
|
||||
}
|
||||
|
||||
private static int setFlag(int displayMode, int flag) {
|
||||
return displayMode | flag;
|
||||
}
|
||||
|
||||
private static int clearFlag(int displayMode, int flag) {
|
||||
return displayMode & ~flag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,27 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.view.AvatarView
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.FromTextView
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
|
||||
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -37,20 +44,19 @@ import org.thoughtcrime.securesms.util.visible
|
||||
open class ContactSearchAdapter(
|
||||
private val context: Context,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: DisplaySmsTag,
|
||||
displaySecondaryInformation: DisplaySecondaryInformation,
|
||||
displayOptions: DisplayOptions,
|
||||
onClickCallbacks: ClickCallbacks,
|
||||
longClickCallbacks: LongClickCallbacks,
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks,
|
||||
callButtonClickCallbacks: CallButtonClickCallbacks
|
||||
) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
|
||||
|
||||
init {
|
||||
registerStoryItems(this, displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks)
|
||||
registerKnownRecipientItems(this, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick)
|
||||
registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
|
||||
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
|
||||
registerHeaders(this)
|
||||
registerExpands(this, onClickCallbacks::onExpandClicked)
|
||||
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayCheckBox) }, R.layout.contact_search_unknown_item))
|
||||
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
|
||||
}
|
||||
|
||||
override fun getBubbleText(position: Int): CharSequence {
|
||||
@@ -71,26 +77,28 @@ open class ContactSearchAdapter(
|
||||
mappingAdapter: MappingAdapter,
|
||||
displayCheckBox: Boolean = false,
|
||||
storyListener: OnClickedCallback<ContactSearchData.Story>,
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null,
|
||||
showStoryRing: Boolean = false
|
||||
) {
|
||||
mappingAdapter.registerFactory(
|
||||
StoryModel::class.java,
|
||||
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
|
||||
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerKnownRecipientItems(
|
||||
mappingAdapter: MappingAdapter,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: DisplaySmsTag,
|
||||
displaySecondaryInformation: DisplaySecondaryInformation,
|
||||
displayOptions: DisplayOptions,
|
||||
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>,
|
||||
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>
|
||||
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
|
||||
recipientCallButtonClickCallbacks: CallButtonClickCallbacks
|
||||
) {
|
||||
mappingAdapter.registerFactory(
|
||||
RecipientModel::class.java,
|
||||
LayoutFactory({ KnownRecipientViewHolder(it, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, recipientListener, recipientLongClickCallback) }, R.layout.contact_search_item)
|
||||
LayoutFactory({
|
||||
KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
|
||||
}, R.layout.contact_search_item)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,15 +166,47 @@ open class ContactSearchAdapter(
|
||||
|
||||
private class StoryViewHolder(
|
||||
itemView: View,
|
||||
displayCheckBox: Boolean,
|
||||
onClick: OnClickedCallback<ContactSearchData.Story>,
|
||||
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?
|
||||
) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, DisplaySmsTag.NEVER, onClick) {
|
||||
override fun isSelected(model: StoryModel): Boolean = model.isSelected
|
||||
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
||||
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
|
||||
val displayCheckBox: Boolean,
|
||||
val onClick: OnClickedCallback<ContactSearchData.Story>,
|
||||
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?,
|
||||
private val showStoryRing: Boolean = false
|
||||
) : MappingViewHolder<StoryModel>(itemView) {
|
||||
|
||||
override fun bindNumberField(model: StoryModel) {
|
||||
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
|
||||
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
|
||||
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
|
||||
val name: FromTextView = itemView.findViewById(R.id.name)
|
||||
val number: TextView = itemView.findViewById(R.id.number)
|
||||
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
|
||||
var storyViewState: Observable<StoryViewState>? = null
|
||||
var storyDisposable: Disposable? = null
|
||||
|
||||
override fun bind(model: StoryModel) {
|
||||
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
|
||||
bindLongPress(model)
|
||||
|
||||
bindCheckbox(model)
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
|
||||
avatar.setStoryRingFromState(StoryViewState.NONE)
|
||||
groupStoryIndicator.isActivated = false
|
||||
|
||||
name.setText(getRecipient(model))
|
||||
badge.setBadgeFromRecipient(getRecipient(model))
|
||||
|
||||
bindAvatar(model)
|
||||
bindNumberField(model)
|
||||
}
|
||||
|
||||
fun isSelected(model: StoryModel): Boolean = model.isSelected
|
||||
fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
||||
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
|
||||
|
||||
fun bindNumberField(model: StoryModel) {
|
||||
number.visible = true
|
||||
|
||||
val count = if (model.story.recipient.isGroup) {
|
||||
@@ -193,17 +233,23 @@ open class ContactSearchAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindAvatar(model: StoryModel) {
|
||||
if (model.story.recipient.isMyStory) {
|
||||
avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp))
|
||||
avatar.setAvatarUsingProfile(Recipient.self())
|
||||
} else {
|
||||
avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
|
||||
super.bindAvatar(model)
|
||||
}
|
||||
fun bindCheckbox(model: StoryModel) {
|
||||
checkbox.visible = displayCheckBox
|
||||
checkbox.isChecked = isSelected(model)
|
||||
}
|
||||
|
||||
override fun bindLongPress(model: StoryModel) {
|
||||
fun bindAvatar(model: StoryModel) {
|
||||
if (model.story.recipient.isMyStory) {
|
||||
avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp))
|
||||
avatar.displayProfileAvatar(Recipient.self())
|
||||
} else {
|
||||
avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
|
||||
avatar.displayChatAvatar(getRecipient(model))
|
||||
}
|
||||
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
|
||||
}
|
||||
|
||||
fun bindLongPress(model: StoryModel) {
|
||||
if (storyContextMenuCallbacks == null) {
|
||||
return
|
||||
}
|
||||
@@ -264,6 +310,20 @@ open class ContactSearchAdapter(
|
||||
return GeneratedContactPhoto(name, R.drawable.symbol_person_40, targetSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
|
||||
avatar.setStoryRingFromState(it)
|
||||
when (it) {
|
||||
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
|
||||
else -> groupStoryIndicator.isActivated = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
storyDisposable?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,18 +342,21 @@ open class ContactSearchAdapter(
|
||||
knownRecipient.recipient.getDisplayName(context)
|
||||
}
|
||||
|
||||
var letter = BreakIteratorCompat.getInstance().apply { setText(name) }.take(1)
|
||||
if (letter != null) {
|
||||
letter = letter.trim { it <= ' ' }
|
||||
if (letter.isNotEmpty()) {
|
||||
val firstChar = letter[0]
|
||||
if (Character.isLetterOrDigit(firstChar)) {
|
||||
return firstChar.uppercaseChar().toString()
|
||||
val letter: CharSequence = BreakIteratorCompat.getInstance()
|
||||
.apply { setText(name) }
|
||||
.asSequence()
|
||||
.map { charSequence -> charSequence.trim { it <= ' ' } }
|
||||
.filter { it.isNotEmpty() }
|
||||
.mapNotNull {
|
||||
when {
|
||||
EmojiUtil.isEmoji(it.toString()) -> it
|
||||
Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
.firstOrNull() ?: "#"
|
||||
|
||||
return "#"
|
||||
return letter
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
|
||||
@@ -334,6 +397,7 @@ open class ContactSearchAdapter(
|
||||
checkbox.isSelected = false
|
||||
name.setText(
|
||||
when (model.data.mode) {
|
||||
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
|
||||
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> R.string.contact_selection_list__unknown_contact
|
||||
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
|
||||
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
|
||||
@@ -349,12 +413,11 @@ open class ContactSearchAdapter(
|
||||
private class KnownRecipientViewHolder(
|
||||
itemView: View,
|
||||
private val fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: DisplaySmsTag,
|
||||
private val displaySecondaryInformation: DisplaySecondaryInformation,
|
||||
displayOptions: DisplayOptions,
|
||||
onClick: OnClickedCallback<ContactSearchData.KnownRecipient>,
|
||||
private val onLongClick: OnLongClickedCallback<ContactSearchData.KnownRecipient>
|
||||
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem {
|
||||
private val onLongClick: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
|
||||
callButtonClickCallbacks: CallButtonClickCallbacks
|
||||
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {
|
||||
|
||||
private var headerLetter: String? = null
|
||||
|
||||
@@ -370,10 +433,10 @@ open class ContactSearchAdapter(
|
||||
val count = recipient.participantIds.size
|
||||
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
|
||||
number.visible = true
|
||||
} else if (displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
|
||||
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
|
||||
number.text = recipient.combinedAboutAndEmoji
|
||||
number.visible = true
|
||||
} else if (displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164()) {
|
||||
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164()) {
|
||||
number.text = PhoneNumberFormatter.prettyPrint(recipient.requireE164())
|
||||
number.visible = true
|
||||
} else {
|
||||
@@ -410,9 +473,9 @@ open class ContactSearchAdapter(
|
||||
*/
|
||||
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
|
||||
itemView: View,
|
||||
private val displayCheckBox: Boolean,
|
||||
private val displaySmsTag: DisplaySmsTag,
|
||||
val onClick: OnClickedCallback<D>
|
||||
val displayOptions: DisplayOptions,
|
||||
val onClick: OnClickedCallback<D>,
|
||||
val onCallButtonClickCallbacks: CallButtonClickCallbacks
|
||||
) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
|
||||
@@ -422,6 +485,8 @@ open class ContactSearchAdapter(
|
||||
protected val number: TextView = itemView.findViewById(R.id.number)
|
||||
protected val label: TextView = itemView.findViewById(R.id.label)
|
||||
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
|
||||
private val startAudio: View = itemView.findViewById(R.id.start_audio)
|
||||
private val startVideo: View = itemView.findViewById(R.id.start_video)
|
||||
|
||||
override fun bind(model: T) {
|
||||
if (isEnabled(model)) {
|
||||
@@ -442,10 +507,11 @@ open class ContactSearchAdapter(
|
||||
bindNumberField(model)
|
||||
bindLabelField(model)
|
||||
bindSmsTagField(model)
|
||||
bindCallButtons(model)
|
||||
}
|
||||
|
||||
protected open fun bindCheckbox(model: T) {
|
||||
checkbox.visible = displayCheckBox
|
||||
checkbox.visible = displayOptions.displayCheckBox
|
||||
checkbox.isChecked = isSelected(model)
|
||||
}
|
||||
|
||||
@@ -476,7 +542,7 @@ open class ContactSearchAdapter(
|
||||
}
|
||||
|
||||
protected open fun bindSmsTagField(model: T) {
|
||||
smsTag.visible = when (displaySmsTag) {
|
||||
smsTag.visible = when (displayOptions.displaySmsTag) {
|
||||
DisplaySmsTag.DEFAULT -> isSmsContact(model)
|
||||
DisplaySmsTag.IF_NOT_REGISTERED -> isNotRegistered(model)
|
||||
DisplaySmsTag.NEVER -> false
|
||||
@@ -485,6 +551,25 @@ open class ContactSearchAdapter(
|
||||
|
||||
protected open fun bindLongPress(model: T) = Unit
|
||||
|
||||
private fun bindCallButtons(model: T) {
|
||||
val recipient = getRecipient(model)
|
||||
if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
|
||||
startVideo.visible = true
|
||||
startAudio.visible = !recipient.isPushGroup
|
||||
|
||||
startVideo.setOnClickListener {
|
||||
onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
|
||||
}
|
||||
|
||||
startAudio.setOnClickListener {
|
||||
onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
|
||||
}
|
||||
} else {
|
||||
startVideo.visible = false
|
||||
startAudio.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSmsContact(model: T): Boolean {
|
||||
return (getRecipient(model).isForceSmsSelection || getRecipient(model).isUnregistered) && !getRecipient(model).isDistributionList
|
||||
}
|
||||
@@ -635,6 +720,14 @@ open class ContactSearchAdapter(
|
||||
ALWAYS
|
||||
}
|
||||
|
||||
data class DisplayOptions(
|
||||
val displayCheckBox: Boolean = false,
|
||||
val displaySmsTag: DisplaySmsTag = DisplaySmsTag.NEVER,
|
||||
val displaySecondaryInformation: DisplaySecondaryInformation = DisplaySecondaryInformation.NEVER,
|
||||
val displayCallButtons: Boolean = false,
|
||||
val displayStoryRing: Boolean = false
|
||||
)
|
||||
|
||||
fun interface OnClickedCallback<D : ContactSearchData> {
|
||||
fun onClicked(view: View, data: D, isSelected: Boolean)
|
||||
}
|
||||
@@ -652,6 +745,16 @@ open class ContactSearchAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
interface CallButtonClickCallbacks {
|
||||
fun onVideoCallButtonClicked(recipient: Recipient)
|
||||
fun onAudioCallButtonClicked(recipient: Recipient)
|
||||
}
|
||||
|
||||
object EmptyCallButtonClickCallbacks : CallButtonClickCallbacks {
|
||||
override fun onVideoCallButtonClicked(recipient: Recipient) = Unit
|
||||
override fun onAudioCallButtonClicked(recipient: Recipient) = Unit
|
||||
}
|
||||
|
||||
interface LongClickCallbacks {
|
||||
fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.thoughtcrime.securesms.contacts.HeaderAction
|
||||
*/
|
||||
class ContactSearchConfiguration private constructor(
|
||||
val query: String?,
|
||||
val hasEmptyState: Boolean,
|
||||
val sections: List<Section>
|
||||
val sections: List<Section>,
|
||||
val emptyStateSections: List<Section>
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -20,6 +20,14 @@ class ContactSearchConfiguration private constructor(
|
||||
open val headerAction: HeaderAction? = null
|
||||
abstract val expandConfig: ExpandConfig?
|
||||
|
||||
/**
|
||||
* Section representing the "extra" item.
|
||||
*/
|
||||
object Empty : Section(SectionKey.EMPTY) {
|
||||
override val includeHeader: Boolean = false
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribution lists and group stories.
|
||||
*
|
||||
@@ -188,6 +196,11 @@ class ContactSearchConfiguration private constructor(
|
||||
* Describes a given section. Useful for labeling sections and managing expansion state.
|
||||
*/
|
||||
enum class SectionKey {
|
||||
/**
|
||||
* A generic empty item
|
||||
*/
|
||||
EMPTY,
|
||||
|
||||
/**
|
||||
* Lists My Stories, distribution lists, as well as group stories.
|
||||
*/
|
||||
@@ -271,6 +284,7 @@ class ContactSearchConfiguration private constructor(
|
||||
* Describes the mode for 'Username' or 'PhoneNumber'
|
||||
*/
|
||||
enum class NewRowMode {
|
||||
NEW_CALL,
|
||||
NEW_CONVERSATION,
|
||||
BLOCK,
|
||||
ADD_TO_GROUP
|
||||
@@ -296,21 +310,47 @@ class ContactSearchConfiguration private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal builder class with build method.
|
||||
*/
|
||||
private class ConfigurationBuilder : Builder {
|
||||
private class EmptyStateBuilder : Builder {
|
||||
private val sections: MutableList<Section> = mutableListOf()
|
||||
|
||||
override var query: String? = null
|
||||
override var hasEmptyState: Boolean = false
|
||||
|
||||
override fun addSection(section: Section) {
|
||||
sections.add(section)
|
||||
}
|
||||
|
||||
override fun withEmptyState(emptyStateBuilderFn: Builder.() -> Unit) {
|
||||
error("Unsupported operation: Already in empty state.")
|
||||
}
|
||||
|
||||
fun build(): List<Section> {
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal builder class with build method.
|
||||
*/
|
||||
private class ConfigurationBuilder : Builder {
|
||||
private val sections: MutableList<Section> = mutableListOf()
|
||||
private val emptyState = EmptyStateBuilder()
|
||||
|
||||
override var query: String? = null
|
||||
|
||||
override fun addSection(section: Section) {
|
||||
sections.add(section)
|
||||
}
|
||||
|
||||
override fun withEmptyState(emptyStateBuilderFn: Builder.() -> Unit) {
|
||||
emptyState.emptyStateBuilderFn()
|
||||
}
|
||||
|
||||
fun build(): ContactSearchConfiguration {
|
||||
return ContactSearchConfiguration(query, hasEmptyState, sections)
|
||||
return ContactSearchConfiguration(
|
||||
query = query,
|
||||
sections = sections,
|
||||
emptyStateSections = emptyState.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +359,6 @@ class ContactSearchConfiguration private constructor(
|
||||
*/
|
||||
interface Builder {
|
||||
var query: String?
|
||||
var hasEmptyState: Boolean
|
||||
|
||||
fun arbitrary(first: String, vararg rest: String) {
|
||||
addSection(Section.Arbitrary(setOf(first) + rest.toSet()))
|
||||
@@ -333,6 +372,8 @@ class ContactSearchConfiguration private constructor(
|
||||
addSection(Section.PhoneNumber(newRowMode))
|
||||
}
|
||||
|
||||
fun withEmptyState(emptyStateBuilderFn: Builder.() -> Unit)
|
||||
|
||||
fun addSection(section: Section)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ class ContactSearchMediator(
|
||||
private val fragment: Fragment,
|
||||
private val fixedContacts: Set<ContactSearchKey> = setOf(),
|
||||
selectionLimits: SelectionLimits,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
|
||||
displaySecondaryInformation: ContactSearchAdapter.DisplaySecondaryInformation,
|
||||
displayOptions: ContactSearchAdapter.DisplayOptions,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
|
||||
private val callbacks: Callbacks = SimpleCallbacks(),
|
||||
performSafetyNumberChecks: Boolean = true,
|
||||
@@ -69,9 +67,7 @@ class ContactSearchMediator(
|
||||
val adapter = adapterFactory.create(
|
||||
context = fragment.requireContext(),
|
||||
fixedContacts = fixedContacts,
|
||||
displayCheckBox = displayCheckBox,
|
||||
displaySmsTag = displaySmsTag,
|
||||
displaySecondaryInformation = displaySecondaryInformation,
|
||||
displayOptions = displayOptions,
|
||||
callbacks = object : ContactSearchAdapter.ClickCallbacks {
|
||||
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
|
||||
toggleStorySelection(view, story, isSelected)
|
||||
@@ -86,7 +82,8 @@ class ContactSearchMediator(
|
||||
}
|
||||
},
|
||||
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
|
||||
storyContextMenuCallbacks = StoryContextMenuCallbacks()
|
||||
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
|
||||
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -230,12 +227,11 @@ class ContactSearchMediator(
|
||||
fun create(
|
||||
context: Context,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
|
||||
displaySecondaryInformation: ContactSearchAdapter.DisplaySecondaryInformation,
|
||||
displayOptions: ContactSearchAdapter.DisplayOptions,
|
||||
callbacks: ContactSearchAdapter.ClickCallbacks,
|
||||
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
|
||||
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
|
||||
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
|
||||
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
|
||||
): PagingMappingAdapter<ContactSearchKey>
|
||||
}
|
||||
|
||||
@@ -243,14 +239,13 @@ class ContactSearchMediator(
|
||||
override fun create(
|
||||
context: Context,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
|
||||
displaySecondaryInformation: ContactSearchAdapter.DisplaySecondaryInformation,
|
||||
displayOptions: ContactSearchAdapter.DisplayOptions,
|
||||
callbacks: ContactSearchAdapter.ClickCallbacks,
|
||||
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
|
||||
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
|
||||
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
|
||||
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
|
||||
): PagingMappingAdapter<ContactSearchKey> {
|
||||
return ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks)
|
||||
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,32 +44,51 @@ class ContactSearchPagedDataSource(
|
||||
|
||||
private var searchCache = SearchCache()
|
||||
private var searchSize = -1
|
||||
private var displayEmptyState: Boolean = false
|
||||
|
||||
/**
|
||||
* When determining when the list is in an empty state, we ignore any arbitrary items, since in general
|
||||
* they are always present. If you'd like arbitrary items to appear even when the list is empty, ensure
|
||||
* they are added to the empty state configuration.
|
||||
*/
|
||||
override fun size(): Int {
|
||||
searchSize = contactConfiguration.sections.sumOf {
|
||||
val (arbitrarySections, nonArbitrarySections) = contactConfiguration.sections.partition {
|
||||
it is ContactSearchConfiguration.Section.Arbitrary
|
||||
}
|
||||
|
||||
val sizeOfNonArbitrarySections = nonArbitrarySections.sumOf {
|
||||
getSectionSize(it, contactConfiguration.query)
|
||||
}
|
||||
|
||||
return if (searchSize == 0 && contactConfiguration.hasEmptyState) {
|
||||
1
|
||||
displayEmptyState = sizeOfNonArbitrarySections == 0
|
||||
searchSize = if (displayEmptyState) {
|
||||
contactConfiguration.emptyStateSections.sumOf {
|
||||
getSectionSize(it, contactConfiguration.query)
|
||||
}
|
||||
} else {
|
||||
searchSize
|
||||
arbitrarySections.sumOf {
|
||||
getSectionSize(it, contactConfiguration.query)
|
||||
} + sizeOfNonArbitrarySections
|
||||
}
|
||||
|
||||
return searchSize
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
|
||||
if (searchSize == 0 && contactConfiguration.hasEmptyState) {
|
||||
return mutableListOf(ContactSearchData.Empty(contactConfiguration.query))
|
||||
val sections: List<ContactSearchConfiguration.Section> = if (displayEmptyState) {
|
||||
contactConfiguration.emptyStateSections
|
||||
} else {
|
||||
contactConfiguration.sections
|
||||
}
|
||||
|
||||
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
|
||||
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = sections.associateWith { getSectionSize(it, contactConfiguration.query) }
|
||||
val startIndex: Index = findIndex(sizeMap, start)
|
||||
val endIndex: Index = findIndex(sizeMap, start + length)
|
||||
|
||||
val indexOfStartSection = contactConfiguration.sections.indexOf(startIndex.category)
|
||||
val indexOfEndSection = contactConfiguration.sections.indexOf(endIndex.category)
|
||||
val indexOfStartSection = sections.indexOf(startIndex.category)
|
||||
val indexOfEndSection = sections.indexOf(endIndex.category)
|
||||
|
||||
val results: List<List<ContactSearchData>> = contactConfiguration.sections.mapIndexed { index, section ->
|
||||
val results: List<List<ContactSearchData>> = sections.mapIndexed { index, section ->
|
||||
if (index in indexOfStartSection..indexOfEndSection) {
|
||||
getSectionData(
|
||||
section = section,
|
||||
@@ -122,6 +141,7 @@ class ContactSearchPagedDataSource(
|
||||
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsIterator(query).getCollectionSize(section, query, null)
|
||||
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Empty -> 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +180,7 @@ class ContactSearchPagedDataSource(
|
||||
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsContactData(section, query, startIndex, endIndex)
|
||||
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
|
||||
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
|
||||
is ContactSearchConfiguration.Section.Empty -> listOf(ContactSearchData.Empty(query))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalTrace;
|
||||
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.StorageUtil;
|
||||
@@ -290,6 +291,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
this.locale = Locale.getDefault();
|
||||
startupStopwatch = new Stopwatch("conversation-open");
|
||||
SignalLocalMetrics.ConversationOpen.start();
|
||||
SignalTrace.beginSection("ConversationOpen");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -747,6 +749,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
startupStopwatch.stop(TAG);
|
||||
SignalLocalMetrics.ConversationOpen.onRenderFinished();
|
||||
listener.onFirstRender();
|
||||
SignalTrace.endSection();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1193,7 +1196,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
if (message.getScheduledDate() != -1) {
|
||||
return;
|
||||
}
|
||||
MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent();
|
||||
|
||||
if (getListAdapter() != null) {
|
||||
setLastSeen(0);
|
||||
@@ -2114,7 +2116,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
@Override
|
||||
public void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args) {
|
||||
if (listener.isInBubble()) {
|
||||
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args));
|
||||
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args.skipSharedElementTransition(true)));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
bodyText.setOnLongClickListener(passthroughClickListener);
|
||||
bodyText.setOnClickListener(passthroughClickListener);
|
||||
bodyText.enableSpoilerFiltering();
|
||||
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
|
||||
}
|
||||
|
||||
@@ -2393,7 +2394,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
mediaThumbnailStub.require().getCorners().getTopRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomLeft()
|
||||
));
|
||||
),
|
||||
false);
|
||||
MediaPreviewCache.INSTANCE.setDrawable(((ThumbnailView) v).getImageDrawable());
|
||||
eventListener.goToMediaPreview(ConversationItem.this, v, args);
|
||||
} else if (slide.getUri() != null) {
|
||||
|
||||
@@ -150,7 +150,7 @@ public class ConversationMessage {
|
||||
: BodyRangeUtil.adjustBodyRanges(messageRecord.getMessageRanges(), mentionsUpdate.getBodyAdjustments());
|
||||
|
||||
styledAndMentionBody = SpannableString.valueOf(mentionsUpdate != null ? mentionsUpdate.getBody() : body);
|
||||
styleResult = MessageStyler.style(bodyRanges, styledAndMentionBody);
|
||||
styleResult = MessageStyler.style(messageRecord.getDateSent(), bodyRanges, styledAndMentionBody);
|
||||
}
|
||||
|
||||
return new ConversationMessage(messageRecord,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.core.view.MenuProvider
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState
|
||||
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Delegate object for managing the conversation options menu
|
||||
*/
|
||||
internal object ConversationOptionsMenu {
|
||||
|
||||
/**
|
||||
* MenuProvider implementation for the conversation options menu.
|
||||
*/
|
||||
class Provider(
|
||||
private val dependencies: Dependencies,
|
||||
private val optionsMenuProviderCallback: Callback,
|
||||
private val lifecycleDisposable: LifecycleDisposable
|
||||
) : MenuProvider, Dependencies by dependencies {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menu.clear()
|
||||
|
||||
val recipient: Recipient? = liveRecipient?.get()
|
||||
val groupActiveState: GroupActiveState? = groupViewModel.groupActiveState.value
|
||||
val isActiveGroup = groupActiveState != null && groupActiveState.isActiveGroup
|
||||
val isActiveV2Group = groupActiveState != null && groupActiveState.isActiveV2Group
|
||||
val isInActiveGroup = groupActiveState != null && !groupActiveState.isActiveGroup
|
||||
|
||||
if (isInMessageRequest() && (recipient != null) && !recipient.isBlocked) {
|
||||
if (isActiveGroup) {
|
||||
menuInflater.inflate(R.menu.conversation_message_requests_group, menu)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.isPushAvailable) {
|
||||
if (recipient!!.expiresInSeconds > 0) {
|
||||
if (!isInActiveGroup) {
|
||||
menuInflater.inflate(R.menu.conversation_expiring_on, menu)
|
||||
}
|
||||
titleView.showExpiring(liveRecipient!!)
|
||||
} else {
|
||||
if (!isInActiveGroup) {
|
||||
menuInflater.inflate(R.menu.conversation_expiring_off, menu)
|
||||
}
|
||||
titleView.clearExpiring()
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingleConversation()) {
|
||||
if (viewModel.isPushAvailable) {
|
||||
menuInflater.inflate(R.menu.conversation_callable_secure, menu)
|
||||
} else if (!recipient!!.isReleaseNotes && SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
menuInflater.inflate(R.menu.conversation_callable_insecure, menu)
|
||||
}
|
||||
} else if (isGroupConversation()) {
|
||||
if (isActiveV2Group) {
|
||||
menuInflater.inflate(R.menu.conversation_callable_groupv2, menu)
|
||||
if (groupCallViewModel != null && java.lang.Boolean.TRUE == groupCallViewModel.hasActiveGroupCall().getValue()) {
|
||||
hideMenuItem(menu, R.id.menu_video_secure)
|
||||
}
|
||||
showGroupCallingTooltip()
|
||||
}
|
||||
menuInflater.inflate(R.menu.conversation_group_options, menu)
|
||||
if (!isPushGroupConversation()) {
|
||||
menuInflater.inflate(R.menu.conversation_mms_group_options, menu)
|
||||
if (distributionType == ThreadTable.DistributionTypes.BROADCAST) {
|
||||
menu.findItem(R.id.menu_distribution_broadcast).isChecked = true
|
||||
} else {
|
||||
menu.findItem(R.id.menu_distribution_conversation).isChecked = true
|
||||
}
|
||||
}
|
||||
menuInflater.inflate(R.menu.conversation_active_group_options, menu)
|
||||
}
|
||||
|
||||
menuInflater.inflate(R.menu.conversation, menu)
|
||||
|
||||
if (isInMessageRequest() && !recipient!!.isBlocked) {
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings)
|
||||
}
|
||||
|
||||
if (isSingleConversation() && !viewModel.isPushAvailable && !recipient!!.isReleaseNotes) {
|
||||
menuInflater.inflate(R.menu.conversation_insecure, menu)
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.isMuted) menuInflater.inflate(R.menu.conversation_muted, menu) else menuInflater.inflate(R.menu.conversation_unmuted, menu)
|
||||
|
||||
if (isSingleConversation() && getRecipient()!!.contactUri == null && !recipient!!.isReleaseNotes && !recipient.isSelf && recipient.hasE164()) {
|
||||
menuInflater.inflate(R.menu.conversation_add_to_contacts, menu)
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.isSelf) {
|
||||
if (viewModel.isPushAvailable) {
|
||||
hideMenuItem(menu, R.id.menu_call_secure)
|
||||
hideMenuItem(menu, R.id.menu_video_secure)
|
||||
} else {
|
||||
hideMenuItem(menu, R.id.menu_call_insecure)
|
||||
}
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications)
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.isBlocked) {
|
||||
if (viewModel.isPushAvailable) {
|
||||
hideMenuItem(menu, R.id.menu_call_secure)
|
||||
hideMenuItem(menu, R.id.menu_video_secure)
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages)
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages_off)
|
||||
} else {
|
||||
hideMenuItem(menu, R.id.menu_call_insecure)
|
||||
}
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications)
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.isReleaseNotes) {
|
||||
hideMenuItem(menu, R.id.menu_add_shortcut)
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_group_recipients)
|
||||
|
||||
if (isActiveV2Group) {
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications)
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings)
|
||||
} else if (isGroupConversation()) {
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings)
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_create_bubble)
|
||||
lifecycleDisposable += viewModel.canShowAsBubble().subscribeBy(onNext = { canShowAsBubble: Boolean ->
|
||||
val item = menu.findItem(R.id.menu_create_bubble)
|
||||
if (item != null) {
|
||||
item.isVisible = canShowAsBubble && !isInBubble()
|
||||
}
|
||||
})
|
||||
|
||||
if (threadId == -1L) {
|
||||
hideMenuItem(menu, R.id.menu_view_media)
|
||||
}
|
||||
|
||||
optionsMenuProviderCallback.onOptionsMenuCreated(menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_call_secure -> optionsMenuProviderCallback.handleDial(getRecipient(), true)
|
||||
R.id.menu_video_secure -> optionsMenuProviderCallback.handleVideo(getRecipient())
|
||||
R.id.menu_call_insecure -> optionsMenuProviderCallback.handleDial(getRecipient(), false)
|
||||
R.id.menu_view_media -> optionsMenuProviderCallback.handleViewMedia()
|
||||
R.id.menu_add_shortcut -> optionsMenuProviderCallback.handleAddShortcut()
|
||||
R.id.menu_search -> optionsMenuProviderCallback.handleSearch()
|
||||
R.id.menu_add_to_contacts -> optionsMenuProviderCallback.handleAddToContacts()
|
||||
R.id.menu_group_recipients -> optionsMenuProviderCallback.handleDisplayGroupRecipients()
|
||||
R.id.menu_distribution_broadcast -> optionsMenuProviderCallback.handleDistributionBroadcastEnabled(menuItem)
|
||||
R.id.menu_distribution_conversation -> optionsMenuProviderCallback.handleDistributionConversationEnabled(menuItem)
|
||||
R.id.menu_group_settings -> optionsMenuProviderCallback.handleManageGroup()
|
||||
R.id.menu_leave -> optionsMenuProviderCallback.handleLeavePushGroup()
|
||||
R.id.menu_invite -> optionsMenuProviderCallback.handleInviteLink()
|
||||
R.id.menu_mute_notifications -> optionsMenuProviderCallback.handleMuteNotifications()
|
||||
R.id.menu_unmute_notifications -> optionsMenuProviderCallback.handleUnmuteNotifications()
|
||||
R.id.menu_conversation_settings -> optionsMenuProviderCallback.handleConversationSettings()
|
||||
R.id.menu_expiring_messages_off, R.id.menu_expiring_messages -> optionsMenuProviderCallback.handleSelectMessageExpiration()
|
||||
R.id.menu_create_bubble -> optionsMenuProviderCallback.handleCreateBubble()
|
||||
R.id.home -> optionsMenuProviderCallback.handleGoHome()
|
||||
else -> return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getRecipient(): Recipient? {
|
||||
return liveRecipient?.get()
|
||||
}
|
||||
|
||||
private fun hideMenuItem(menu: Menu, @IdRes menuItem: Int) {
|
||||
if (menu.findItem(menuItem) != null) {
|
||||
menu.findItem(menuItem).isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSingleConversation(): Boolean = getRecipient()?.isGroup == false
|
||||
|
||||
private fun isGroupConversation(): Boolean = getRecipient()?.isGroup == true
|
||||
|
||||
private fun isPushGroupConversation(): Boolean = getRecipient()?.isPushGroup == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies abstraction for the conversation options menu
|
||||
*/
|
||||
interface Dependencies {
|
||||
val liveRecipient: LiveRecipient?
|
||||
val viewModel: ConversationViewModel
|
||||
val groupViewModel: ConversationGroupViewModel
|
||||
val groupCallViewModel: GroupCallViewModel?
|
||||
val titleView: ConversationTitleView
|
||||
val distributionType: Int
|
||||
val threadId: Long
|
||||
fun isInMessageRequest(): Boolean
|
||||
fun showGroupCallingTooltip()
|
||||
fun isInBubble(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks abstraction for the converstaion options menu
|
||||
*/
|
||||
interface Callback {
|
||||
fun onOptionsMenuCreated(menu: Menu)
|
||||
|
||||
fun handleVideo(recipient: Recipient?)
|
||||
fun handleDial(recipient: Recipient?, isSecure: Boolean)
|
||||
fun handleViewMedia()
|
||||
fun handleAddShortcut()
|
||||
fun handleSearch()
|
||||
fun handleAddToContacts()
|
||||
fun handleDisplayGroupRecipients()
|
||||
fun handleDistributionBroadcastEnabled(menuItem: MenuItem)
|
||||
fun handleDistributionConversationEnabled(menuItem: MenuItem)
|
||||
fun handleManageGroup()
|
||||
fun handleLeavePushGroup()
|
||||
fun handleInviteLink()
|
||||
fun handleMuteNotifications()
|
||||
fun handleUnmuteNotifications()
|
||||
fun handleConversationSettings()
|
||||
fun handleSelectMessageExpiration()
|
||||
fun handleCreateBubble()
|
||||
fun handleGoHome()
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ActivityNotFoundException;
|
||||
@@ -49,7 +48,6 @@ import android.text.method.LinkMovementMethod;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
@@ -145,6 +143,7 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
|
||||
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
@@ -157,8 +156,6 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
|
||||
@@ -179,7 +176,6 @@ import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
@@ -240,7 +236,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.mms.QuoteId;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
@@ -284,6 +279,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.Dialogs;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
@@ -366,7 +362,9 @@ public class ConversationParentFragment extends Fragment
|
||||
MessageDetailsFragment.Callback,
|
||||
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
|
||||
ConversationBottomSheetCallback,
|
||||
ScheduleMessageDialogCallback
|
||||
ScheduleMessageDialogCallback,
|
||||
ConversationOptionsMenu.Callback,
|
||||
ConversationOptionsMenu.Dependencies
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
@@ -475,6 +473,8 @@ public class ConversationParentFragment extends Fragment
|
||||
private Callback callback;
|
||||
private RecentEmojiPageModel recentEmojis;
|
||||
|
||||
private ConversationOptionsMenu.Provider menuProvider;
|
||||
|
||||
public static ConversationParentFragment create(Intent intent) {
|
||||
ConversationParentFragment fragment = new ConversationParentFragment();
|
||||
Bundle bundle = new Bundle();
|
||||
@@ -493,6 +493,8 @@ public class ConversationParentFragment extends Fragment
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
disposables.bindTo(getViewLifecycleOwner());
|
||||
menuProvider = new ConversationOptionsMenu.Provider(this, this, disposables);
|
||||
SpoilerAnnotation.resetRevealedSpoilers();
|
||||
|
||||
if (requireActivity() instanceof Callback) {
|
||||
callback = (Callback) requireActivity();
|
||||
@@ -572,7 +574,7 @@ public class ConversationParentFragment extends Fragment
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), backPressedCallback);
|
||||
|
||||
if (isSearchRequested && savedInstanceState == null) {
|
||||
onCreateOptionsMenu(toolbar.getMenu(), requireActivity().getMenuInflater());
|
||||
menuProvider.onCreateMenu(toolbar.getMenu(), requireActivity().getMenuInflater());
|
||||
}
|
||||
|
||||
sendButton.post(() -> sendButton.triggerSelectedChangedEvent());
|
||||
@@ -910,132 +912,7 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
menu.clear();
|
||||
|
||||
GroupActiveState groupActiveState = groupViewModel.getGroupActiveState().getValue();
|
||||
boolean isActiveGroup = groupActiveState != null && groupActiveState.isActiveGroup();
|
||||
boolean isActiveV2Group = groupActiveState != null && groupActiveState.isActiveV2Group();
|
||||
boolean isInActiveGroup = groupActiveState != null && !groupActiveState.isActiveGroup();
|
||||
|
||||
if (isInMessageRequest() && recipient != null && !recipient.get().isBlocked()) {
|
||||
if (isActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_message_requests_group, menu);
|
||||
}
|
||||
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
}
|
||||
|
||||
if (viewModel.isPushAvailable()) {
|
||||
if (recipient.get().getExpiresInSeconds() > 0) {
|
||||
if (!isInActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_expiring_on, menu);
|
||||
}
|
||||
titleView.showExpiring(recipient);
|
||||
} else {
|
||||
if (!isInActiveGroup) {
|
||||
inflater.inflate(R.menu.conversation_expiring_off, menu);
|
||||
}
|
||||
titleView.clearExpiring();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingleConversation()) {
|
||||
if (viewModel.isPushAvailable()) {
|
||||
inflater.inflate(R.menu.conversation_callable_secure, menu);
|
||||
} else if (!recipient.get().isReleaseNotes() && SignalStore.misc().getSmsExportPhase().allowSmsFeatures()) {
|
||||
inflater.inflate(R.menu.conversation_callable_insecure, menu);
|
||||
}
|
||||
} else if (isGroupConversation()) {
|
||||
if (isActiveV2Group && Build.VERSION.SDK_INT > 19) {
|
||||
inflater.inflate(R.menu.conversation_callable_groupv2, menu);
|
||||
if (groupCallViewModel != null && Boolean.TRUE.equals(groupCallViewModel.hasActiveGroupCall().getValue())) {
|
||||
hideMenuItem(menu, R.id.menu_video_secure);
|
||||
}
|
||||
showGroupCallingTooltip();
|
||||
}
|
||||
|
||||
inflater.inflate(R.menu.conversation_group_options, menu);
|
||||
|
||||
if (!isPushGroupConversation()) {
|
||||
inflater.inflate(R.menu.conversation_mms_group_options, menu);
|
||||
if (distributionType == ThreadTable.DistributionTypes.BROADCAST) {
|
||||
menu.findItem(R.id.menu_distribution_broadcast).setChecked(true);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_distribution_conversation).setChecked(true);
|
||||
}
|
||||
}
|
||||
|
||||
inflater.inflate(R.menu.conversation_active_group_options, menu);
|
||||
}
|
||||
|
||||
inflater.inflate(R.menu.conversation, menu);
|
||||
|
||||
if (isInMessageRequest() && !recipient.get().isBlocked()) {
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings);
|
||||
}
|
||||
|
||||
if (isSingleConversation() && !viewModel.isPushAvailable() && !recipient.get().isReleaseNotes()) {
|
||||
inflater.inflate(R.menu.conversation_insecure, menu);
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu);
|
||||
else inflater.inflate(R.menu.conversation_unmuted, menu);
|
||||
|
||||
if (isSingleConversation() && getRecipient().getContactUri() == null && !recipient.get().isReleaseNotes() && !recipient.get().isSelf() && recipient.get().hasE164()) {
|
||||
inflater.inflate(R.menu.conversation_add_to_contacts, menu);
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.get().isSelf()) {
|
||||
if (viewModel.isPushAvailable()) {
|
||||
hideMenuItem(menu, R.id.menu_call_secure);
|
||||
hideMenuItem(menu, R.id.menu_video_secure);
|
||||
} else {
|
||||
hideMenuItem(menu, R.id.menu_call_insecure);
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.get().isBlocked()) {
|
||||
if (viewModel.isPushAvailable()) {
|
||||
hideMenuItem(menu, R.id.menu_call_secure);
|
||||
hideMenuItem(menu, R.id.menu_video_secure);
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages);
|
||||
hideMenuItem(menu, R.id.menu_expiring_messages_off);
|
||||
} else {
|
||||
hideMenuItem(menu, R.id.menu_call_insecure);
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.get().isReleaseNotes()) {
|
||||
hideMenuItem(menu, R.id.menu_add_shortcut);
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_group_recipients);
|
||||
|
||||
if (isActiveV2Group) {
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications);
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings);
|
||||
} else if (isGroupConversation()) {
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings);
|
||||
}
|
||||
|
||||
hideMenuItem(menu, R.id.menu_create_bubble);
|
||||
disposables.add(viewModel.canShowAsBubble().subscribe(canShowAsBubble -> {
|
||||
MenuItem item = menu.findItem(R.id.menu_create_bubble);
|
||||
|
||||
if (item != null) {
|
||||
item.setVisible(canShowAsBubble && !isInBubble());
|
||||
}
|
||||
}));
|
||||
|
||||
if (threadId == -1L) {
|
||||
hideMenuItem(menu, R.id.menu_view_media);
|
||||
}
|
||||
|
||||
public void onOptionsMenuCreated(@NonNull Menu menu) {
|
||||
searchViewItem = menu.findItem(R.id.menu_search);
|
||||
|
||||
SearchView searchView = (SearchView) searchViewItem.getActionView();
|
||||
@@ -1093,12 +970,10 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
if (isSearchRequested) {
|
||||
if (searchViewItem.expandActionView()) {
|
||||
searchViewModel.onSearchOpened();
|
||||
}
|
||||
searchViewModel.onSearchOpened();
|
||||
}
|
||||
}
|
||||
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
int toolbarTextAndIconColor = getResources().getColor(wallpaper.getDrawable() != null ? R.color.signal_colorNeutralInverse : R.color.signal_colorOnSurface);
|
||||
setToolbarActionItemTint(toolbar, toolbarTextAndIconColor);
|
||||
}
|
||||
@@ -1107,62 +982,12 @@ public class ConversationParentFragment extends Fragment
|
||||
if (!isSearchRequested && getActivity() != null) {
|
||||
optionsMenuDebouncer.publish(() -> {
|
||||
if (getActivity() != null) {
|
||||
onCreateOptionsMenu(toolbar.getMenu(), requireActivity().getMenuInflater());
|
||||
menuProvider.onCreateMenu(toolbar.getMenu(), requireActivity().getMenuInflater());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
int itemId = item.getItemId();
|
||||
|
||||
if (itemId == R.id.menu_call_secure) {
|
||||
handleDial(getRecipient(), true);
|
||||
} else if (itemId == R.id.menu_video_secure) {
|
||||
handleVideo(getRecipient());
|
||||
} else if (itemId == R.id.menu_call_insecure) {
|
||||
handleDial(getRecipient(), false);
|
||||
} else if (itemId == R.id.menu_view_media) {
|
||||
handleViewMedia();
|
||||
} else if (itemId == R.id.menu_add_shortcut) {
|
||||
handleAddShortcut();
|
||||
} else if (itemId == R.id.menu_search) {
|
||||
handleSearch();
|
||||
} else if (itemId == R.id.menu_add_to_contacts) {
|
||||
handleAddToContacts();
|
||||
} else if (itemId == R.id.menu_group_recipients) {
|
||||
handleDisplayGroupRecipients();
|
||||
} else if (itemId == R.id.menu_distribution_broadcast) {
|
||||
handleDistributionBroadcastEnabled(item);
|
||||
} else if (itemId == R.id.menu_distribution_conversation) {
|
||||
handleDistributionConversationEnabled(item);
|
||||
} else if (itemId == R.id.menu_group_settings) {
|
||||
handleManageGroup();
|
||||
} else if (itemId == R.id.menu_leave) {
|
||||
handleLeavePushGroup();
|
||||
} else if (itemId == R.id.menu_invite) {
|
||||
handleInviteLink();
|
||||
} else if (itemId == R.id.menu_mute_notifications) {
|
||||
handleMuteNotifications();
|
||||
} else if (itemId == R.id.menu_unmute_notifications) {
|
||||
handleUnmuteNotifications();
|
||||
} else if (itemId == R.id.menu_conversation_settings) {
|
||||
handleConversationSettings();
|
||||
} else if (itemId == R.id.menu_expiring_messages_off || itemId == R.id.menu_expiring_messages) {
|
||||
handleSelectMessageExpiration();
|
||||
} else if (itemId == R.id.menu_create_bubble) {
|
||||
handleCreateBubble();
|
||||
} else if (itemId == android.R.id.home) {
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onBackPressed() {
|
||||
Log.d(TAG, "onBackPressed()");
|
||||
if (reactionDelegate.isShowing()) {
|
||||
@@ -1251,7 +1076,8 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
//////// Event Handlers
|
||||
|
||||
private void handleSelectMessageExpiration() {
|
||||
@Override
|
||||
public void handleSelectMessageExpiration() {
|
||||
if (isPushGroupConversation() && !isActiveGroup()) {
|
||||
return;
|
||||
}
|
||||
@@ -1259,17 +1085,9 @@ public class ConversationParentFragment extends Fragment
|
||||
startActivity(RecipientDisappearingMessagesActivity.forRecipient(requireContext(), recipient.getId()));
|
||||
}
|
||||
|
||||
private void handleMuteNotifications() {
|
||||
MuteDialog.show(requireActivity(), until -> {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
SignalDatabase.recipients().setMuted(recipient.getId(), until);
|
||||
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
});
|
||||
@Override
|
||||
public void handleMuteNotifications() {
|
||||
MuteDialog.show(requireActivity(), viewModel::muteConversation);
|
||||
}
|
||||
|
||||
private void handleStoryRingClick() {
|
||||
@@ -1280,7 +1098,8 @@ public class ConversationParentFragment extends Fragment
|
||||
.build()));
|
||||
}
|
||||
|
||||
private void handleConversationSettings() {
|
||||
@Override
|
||||
public void handleConversationSettings() {
|
||||
if (isGroupConversation()) {
|
||||
handleManageGroup();
|
||||
return;
|
||||
@@ -1294,7 +1113,8 @@ public class ConversationParentFragment extends Fragment
|
||||
ActivityCompat.startActivity(requireActivity(), intent, bundle);
|
||||
}
|
||||
|
||||
private void handleUnmuteNotifications() {
|
||||
@Override
|
||||
public void handleUnmuteNotifications() {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
@@ -1321,7 +1141,8 @@ public class ConversationParentFragment extends Fragment
|
||||
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()));
|
||||
}
|
||||
|
||||
private void handleInviteLink() {
|
||||
@Override
|
||||
public void handleInviteLink() {
|
||||
String inviteText = getString(R.string.ConversationActivity_lets_switch_to_signal, getString(R.string.install_url));
|
||||
|
||||
if (viewModel.isDefaultSmsApplication() && SignalStore.misc().getSmsExportPhase().isSmsSupported()) {
|
||||
@@ -1345,11 +1166,13 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void handleViewMedia() {
|
||||
@Override
|
||||
public void handleViewMedia() {
|
||||
startActivity(MediaOverviewActivity.forThread(requireContext(), threadId));
|
||||
}
|
||||
|
||||
private void handleAddShortcut() {
|
||||
@Override
|
||||
public void handleAddShortcut() {
|
||||
Log.i(TAG, "Creating home screen shortcut for recipient " + recipient.get().getId());
|
||||
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
@@ -1394,7 +1217,8 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
}
|
||||
|
||||
private void handleCreateBubble() {
|
||||
@Override
|
||||
public void handleCreateBubble() {
|
||||
ConversationIntents.Args args = viewModel.getArgs();
|
||||
|
||||
BubbleUtil.displayAsBubble(requireContext(), args.getRecipientId(), args.getThreadId());
|
||||
@@ -1423,11 +1247,13 @@ public class ConversationParentFragment extends Fragment
|
||||
bitmap.recycle();
|
||||
}
|
||||
|
||||
private void handleSearch() {
|
||||
@Override
|
||||
public void handleSearch() {
|
||||
searchViewModel.onSearchOpened();
|
||||
}
|
||||
|
||||
private void handleLeavePushGroup() {
|
||||
@Override
|
||||
public void handleLeavePushGroup() {
|
||||
if (getRecipient() == null) {
|
||||
Toast.makeText(requireContext(), getString(R.string.ConversationActivity_invalid_recipient),
|
||||
Toast.LENGTH_LONG).show();
|
||||
@@ -1437,46 +1263,32 @@ public class ConversationParentFragment extends Fragment
|
||||
LeaveGroupDialog.handleLeavePushGroup(requireActivity(), getRecipient().requireGroupId().requirePush(), () -> requireActivity().finish());
|
||||
}
|
||||
|
||||
private void handleManageGroup() {
|
||||
@Override
|
||||
public void handleManageGroup() {
|
||||
Intent intent = ConversationSettingsActivity.forGroup(requireContext(), recipient.get().requireGroupId());
|
||||
Bundle bundle = ConversationSettingsActivity.createTransitionBundle(requireContext(), titleView.findViewById(R.id.contact_photo_image), toolbar);
|
||||
|
||||
ActivityCompat.startActivity(requireContext(), intent, bundle);
|
||||
}
|
||||
|
||||
private void handleDistributionBroadcastEnabled(MenuItem item) {
|
||||
@Override
|
||||
public void handleDistributionBroadcastEnabled(MenuItem item) {
|
||||
distributionType = ThreadTable.DistributionTypes.BROADCAST;
|
||||
draftViewModel.setDistributionType(distributionType);
|
||||
viewModel.setDistributionType(distributionType);
|
||||
item.setChecked(true);
|
||||
|
||||
if (threadId != -1) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
SignalDatabase.threads().setDistributionType(threadId, ThreadTable.DistributionTypes.BROADCAST);
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDistributionConversationEnabled(MenuItem item) {
|
||||
@Override
|
||||
public void handleDistributionConversationEnabled(MenuItem item) {
|
||||
distributionType = ThreadTable.DistributionTypes.CONVERSATION;
|
||||
draftViewModel.setDistributionType(distributionType);
|
||||
viewModel.setDistributionType(distributionType);
|
||||
item.setChecked(true);
|
||||
|
||||
if (threadId != -1) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
SignalDatabase.threads().setDistributionType(threadId, ThreadTable.DistributionTypes.CONVERSATION);
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDial(final Recipient recipient, boolean isSecure) {
|
||||
@Override
|
||||
public void handleDial(final Recipient recipient, boolean isSecure) {
|
||||
if (recipient == null) return;
|
||||
|
||||
if (isSecure) {
|
||||
@@ -1486,7 +1298,8 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void handleVideo(final Recipient recipient) {
|
||||
@Override
|
||||
public void handleVideo(final Recipient recipient) {
|
||||
if (recipient == null) return;
|
||||
|
||||
if (recipient.isPushV2Group() && groupCallViewModel.hasActiveGroupCall().getValue() == Boolean.FALSE && groupViewModel.isNonAdminInAnnouncementGroup()) {
|
||||
@@ -1499,11 +1312,13 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDisplayGroupRecipients() {
|
||||
@Override
|
||||
public void handleDisplayGroupRecipients() {
|
||||
new GroupMembersDialog(requireActivity(), getRecipient()).display();
|
||||
}
|
||||
|
||||
private void handleAddToContacts() {
|
||||
@Override
|
||||
public void handleAddToContacts() {
|
||||
if (recipient.get().isGroup()) return;
|
||||
|
||||
try {
|
||||
@@ -1847,7 +1662,21 @@ public class ConversationParentFragment extends Fragment
|
||||
break;
|
||||
case Draft.QUOTE:
|
||||
SettableFuture<Boolean> quoteResult = new SettableFuture<>();
|
||||
new QuoteRestorationTask(draft.getValue(), quoteResult).execute();
|
||||
disposables.add(draftViewModel.loadDraftQuote(draft.getValue()).subscribe(
|
||||
conversationMessage -> {
|
||||
handleReplyMessage(conversationMessage);
|
||||
quoteResult.set(true);
|
||||
},
|
||||
err -> {
|
||||
Log.e(TAG, "Failed to restore a quote from a draft.", err);
|
||||
quoteResult.set(false);
|
||||
},
|
||||
() -> {
|
||||
Log.e(TAG, "Failed to restore a quote from a draft. No matching message record.");
|
||||
quoteResult.set(false);
|
||||
}
|
||||
));
|
||||
|
||||
quoteResult.addListener(listener);
|
||||
break;
|
||||
case Draft.VOICE_NOTE:
|
||||
@@ -2256,7 +2085,7 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
protected void initializeActionBar() {
|
||||
invalidateOptionsMenu();
|
||||
toolbar.setOnMenuItemClickListener(this::onOptionsItemSelected);
|
||||
toolbar.setOnMenuItemClickListener(menuProvider::onMenuItemSelected);
|
||||
|
||||
if (isInBubble()) {
|
||||
toolbar.setNavigationIcon(DrawableUtil.tint(ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
|
||||
@@ -2494,7 +2323,8 @@ public class ConversationParentFragment extends Fragment
|
||||
);
|
||||
}
|
||||
|
||||
private void showGroupCallingTooltip() {
|
||||
@Override
|
||||
public void showGroupCallingTooltip() {
|
||||
if (!SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) {
|
||||
return;
|
||||
}
|
||||
@@ -2859,7 +2689,8 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isInMessageRequest() {
|
||||
@Override
|
||||
public boolean isInMessageRequest() {
|
||||
return messageRequestBottomView.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
@@ -2890,11 +2721,17 @@ public class ConversationParentFragment extends Fragment
|
||||
return sendButton.isManualSelection() && sendButton.getSelectedSendType().usesSmsTransport();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable LiveRecipient getLiveRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
protected Recipient getRecipient() {
|
||||
return this.recipient.get();
|
||||
}
|
||||
|
||||
protected long getThreadId() {
|
||||
@Override
|
||||
public long getThreadId() {
|
||||
return this.threadId;
|
||||
}
|
||||
|
||||
@@ -3020,6 +2857,11 @@ public class ConversationParentFragment extends Fragment
|
||||
return;
|
||||
}
|
||||
|
||||
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && result.getBodyRanges() != null && result.getBodyRanges().getRangesCount() > 0) {
|
||||
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
long thread = this.threadId;
|
||||
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
||||
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
|
||||
@@ -3132,6 +2974,12 @@ public class ConversationParentFragment extends Fragment
|
||||
return new SettableFuture<>(null);
|
||||
}
|
||||
|
||||
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
|
||||
final String finalBody = body;
|
||||
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
|
||||
return new SettableFuture<>(null);
|
||||
}
|
||||
|
||||
final boolean sendPush = sendType.usesSignalTransport();
|
||||
final long thread = this.threadId;
|
||||
|
||||
@@ -3381,13 +3229,8 @@ public class ConversationParentFragment extends Fragment
|
||||
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED);
|
||||
|
||||
voiceNoteMediaController.pausePlayback();
|
||||
try {
|
||||
recordingSession = new RecordingSession(audioRecorder.startRecording());
|
||||
disposables.add(recordingSession);
|
||||
} catch (AssertionError err) {
|
||||
Log.e(TAG, "Could not start audio recording.", err);
|
||||
Toast.makeText(requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
recordingSession = new RecordingSession(audioRecorder.startRecording());
|
||||
disposables.add(recordingSession);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -3702,6 +3545,36 @@ public class ConversationParentFragment extends Fragment
|
||||
sendMessage(metricId, scheduledDate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleGoHome() {
|
||||
requireActivity().finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ConversationViewModel getViewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ConversationGroupViewModel getGroupViewModel() {
|
||||
return groupViewModel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable GroupCallViewModel getGroupCallViewModel() {
|
||||
return groupCallViewModel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ConversationTitleView getTitleView() {
|
||||
return titleView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDistributionType() {
|
||||
return distributionType;
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private class RecordingSession implements SingleObserver<VoiceNoteDraft>, Disposable {
|
||||
@@ -4291,12 +4164,6 @@ public class ConversationParentFragment extends Fragment
|
||||
BlockUnblockDialog.showUnblockFor(requireContext(), getLifecycle(), recipient, requestModel::onUnblock);
|
||||
}
|
||||
|
||||
private static void hideMenuItem(@NonNull Menu menu, @IdRes int menuItem) {
|
||||
if (menu.findItem(menuItem) != null) {
|
||||
menu.findItem(menuItem).setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @Nullable KeyboardImageDetails getKeyboardImageDetails(@NonNull Uri uri) {
|
||||
try {
|
||||
@@ -4387,50 +4254,6 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private class QuoteRestorationTask extends AsyncTask<Void, Void, ConversationMessage> {
|
||||
|
||||
private final String serialized;
|
||||
private final SettableFuture<Boolean> future;
|
||||
|
||||
QuoteRestorationTask(@NonNull String serialized, @NonNull SettableFuture<Boolean> future) {
|
||||
this.serialized = serialized;
|
||||
this.future = future;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ConversationMessage doInBackground(Void... voids) {
|
||||
QuoteId quoteId = QuoteId.deserialize(ApplicationDependencies.getApplication(), serialized);
|
||||
|
||||
if (quoteId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
|
||||
MessageRecord messageRecord = SignalDatabase.messages().getMessageFor(quoteId.getId(), quoteId.getAuthor());
|
||||
if (messageRecord == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (messageRecord instanceof MediaMmsMessageRecord) {
|
||||
messageRecord = ((MediaMmsMessageRecord) messageRecord).withAttachments(context, SignalDatabase.attachments().getAttachmentsForMessage(messageRecord.getId()));
|
||||
}
|
||||
|
||||
return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(ConversationMessage conversationMessage) {
|
||||
if (conversationMessage != null) {
|
||||
handleReplyMessage(conversationMessage);
|
||||
future.set(true);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to restore a quote from a draft. No matching message record.");
|
||||
future.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener {
|
||||
@Override
|
||||
public void onCloseRequested(@NonNull Uri uri) {
|
||||
|
||||
@@ -105,7 +105,7 @@ class ConversationRepository {
|
||||
conversationRecipient.getExpiresInSeconds() == 0 &&
|
||||
!conversationRecipient.isGroup() &&
|
||||
conversationRecipient.isRegistered() &&
|
||||
(threadId == -1 || !SignalDatabase.messages().hasMeaningfulMessage(threadId)))
|
||||
(threadId == -1 || SignalDatabase.messages().canSetUniversalTimer(threadId)))
|
||||
{
|
||||
showUniversalExpireTimerUpdate = true;
|
||||
}
|
||||
@@ -212,4 +212,12 @@ class ConversationRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setConversationMuted(@NonNull RecipientId recipientId, long until) {
|
||||
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().setMuted(recipientId, until));
|
||||
}
|
||||
|
||||
public void setConversationDistributionType(long threadId, int distributionType) {
|
||||
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.threads().setDistributionType(threadId, distributionType));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class ConversationTitleView extends RelativeLayout {
|
||||
public class ConversationTitleView extends ConstraintLayout {
|
||||
|
||||
private static final String STATE_ROOT = "root";
|
||||
private static final String STATE_IS_SELF = "is_self";
|
||||
|
||||
@@ -260,6 +260,15 @@ public class ConversationViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
void setDistributionType(int distributionType) {
|
||||
Long threadId = this.threadId.getValue();
|
||||
if (threadId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversationRepository.setConversationDistributionType(threadId, distributionType);
|
||||
}
|
||||
|
||||
void submitMarkReadRequest(long timestampSince) {
|
||||
markReadRequestPublisher.onNext(timestampSince);
|
||||
}
|
||||
@@ -338,6 +347,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
return conversationStateStore.getState().getSecurityInfo().isPushAvailable();
|
||||
}
|
||||
|
||||
void muteConversation(long until) {
|
||||
conversationRepository.setConversationMuted(args.getRecipientId(), until);
|
||||
}
|
||||
|
||||
@NonNull ConversationState getConversationStateSnapshot() {
|
||||
return conversationStateStore.getState();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.Annotation
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,10 @@ import org.thoughtcrime.securesms.util.PlaceholderURLSpan
|
||||
object MessageStyler {
|
||||
|
||||
const val MONOSPACE = "monospace"
|
||||
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
|
||||
const val DRAFT_ID = "DRAFT"
|
||||
const val COMPOSE_ID = "COMPOSE"
|
||||
const val QUOTE_ID = "QUOTE"
|
||||
|
||||
@JvmStatic
|
||||
fun boldStyle(): CharacterStyle {
|
||||
@@ -39,7 +44,13 @@ object MessageStyler {
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun style(messageRanges: BodyRangeList?, span: Spannable): Result {
|
||||
fun spoilerStyle(id: Any, start: Int, length: Int): Annotation {
|
||||
return SpoilerAnnotation.spoilerAnnotation(arrayOf(id, start, length).contentHashCode())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun style(id: Any, messageRanges: BodyRangeList?, span: Spannable, hideSpoilerText: Boolean = true): Result {
|
||||
if (messageRanges == null) {
|
||||
return Result.none()
|
||||
}
|
||||
@@ -50,23 +61,33 @@ object MessageStyler {
|
||||
|
||||
messageRanges
|
||||
.rangesList
|
||||
.filter { r -> r.start >= 0 && r.start < span.length && r.start + r.length >= 0 && r.start + r.length <= span.length }
|
||||
.filter { r -> r.start >= 0 && r.start < span.length && r.start + r.length >= 0 }
|
||||
.forEach { range ->
|
||||
val start = range.start
|
||||
val end = (range.start + range.length).coerceAtMost(span.length)
|
||||
|
||||
if (range.hasStyle()) {
|
||||
val styleSpan: CharacterStyle? = when (range.style) {
|
||||
val styleSpan: Any? = when (range.style) {
|
||||
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
|
||||
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
|
||||
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
|
||||
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
|
||||
BodyRangeList.BodyRange.Style.SPOILER -> {
|
||||
val spoiler = spoilerStyle(id, range.start, range.length)
|
||||
if (hideSpoilerText) {
|
||||
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), start, end, SPAN_FLAGS)
|
||||
}
|
||||
spoiler
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (styleSpan != null) {
|
||||
span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
|
||||
span.setSpan(styleSpan, start, end, SPAN_FLAGS)
|
||||
appliedStyle = true
|
||||
}
|
||||
} else if (range.hasLink() && range.link != null) {
|
||||
span.setSpan(PlaceholderURLSpan(range.link), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
span.setSpan(PlaceholderURLSpan(range.link), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
hasLinks = true
|
||||
} else if (range.hasButton() && range.button != null) {
|
||||
bottomButton = range.button
|
||||
@@ -82,38 +103,47 @@ object MessageStyler {
|
||||
|
||||
@JvmStatic
|
||||
fun hasStyling(text: Spanned): Boolean {
|
||||
return if (FeatureFlags.textFormatting()) {
|
||||
text.getSpans(0, text.length, CharacterStyle::class.java)
|
||||
.any { s -> isSupportedCharacterStyle(s) && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
return text
|
||||
.getSpans(0, text.length, Object::class.java)
|
||||
.any { s -> s.isSupportedStyle() && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getStyling(text: CharSequence?): BodyRangeList? {
|
||||
val bodyRanges = if (text is Spanned && FeatureFlags.textFormatting()) {
|
||||
val bodyRanges = if (text is Spanned) {
|
||||
text
|
||||
.getSpans(0, text.length, CharacterStyle::class.java)
|
||||
.filter { s -> isSupportedCharacterStyle(s) }
|
||||
.mapNotNull { span: CharacterStyle ->
|
||||
.getSpans(0, text.length, Object::class.java)
|
||||
.filter { s -> s.isSupportedStyle() }
|
||||
.mapNotNull { span ->
|
||||
val spanStart = text.getSpanStart(span)
|
||||
val spanLength = text.getSpanEnd(span) - spanStart
|
||||
|
||||
val style = when (span) {
|
||||
is StyleSpan -> if (span.style == Typeface.BOLD) BodyRangeList.BodyRange.Style.BOLD else BodyRangeList.BodyRange.Style.ITALIC
|
||||
val style: BodyRangeList.BodyRange.Style? = when (span) {
|
||||
is StyleSpan -> {
|
||||
when (span.style) {
|
||||
Typeface.BOLD -> BodyRangeList.BodyRange.Style.BOLD
|
||||
Typeface.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
|
||||
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
|
||||
is Annotation -> {
|
||||
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
|
||||
BodyRangeList.BodyRange.Style.SPOILER
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
|
||||
}
|
||||
|
||||
if (spanLength > 0) {
|
||||
if (spanLength > 0 && style != null) {
|
||||
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
@@ -125,6 +155,14 @@ object MessageStyler {
|
||||
}
|
||||
}
|
||||
|
||||
fun Any.isSupportedStyle(): Boolean {
|
||||
return when (this) {
|
||||
is CharacterStyle -> isSupportedCharacterStyle(this)
|
||||
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
|
||||
return when (style) {
|
||||
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
class SignalBottomActionBarController(
|
||||
private val bottomActionBar: SignalBottomActionBar,
|
||||
private val recyclerView: RecyclerView,
|
||||
private val callback: Callback
|
||||
) {
|
||||
|
||||
private val additionalScrollOffset = 54.dp
|
||||
private val paddingBottom: Int = recyclerView.paddingBottom
|
||||
|
||||
fun setVisibility(isVisible: Boolean) {
|
||||
val isCurrentlyVisible = bottomActionBar.isVisible
|
||||
if (isVisible == isCurrentlyVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ViewUtil.animateIn(bottomActionBar, bottomActionBar.enterAnimation)
|
||||
callback.onBottomActionBarVisibilityChanged(View.VISIBLE)
|
||||
|
||||
bottomActionBar.viewTreeObserver.addOnPreDrawListener(BecomingVisiblePreDrawListener())
|
||||
} else {
|
||||
ViewUtil
|
||||
.animateOut(bottomActionBar, bottomActionBar.exitAnimation)
|
||||
.addListener(BecomingGoneAnimationListener())
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BecomingVisiblePreDrawListener : ViewTreeObserver.OnPreDrawListener {
|
||||
|
||||
private val bottomPaddingExtra = 18.dp
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
if (bottomActionBar.height == 0 && bottomActionBar.visibility == View.VISIBLE) {
|
||||
return false
|
||||
}
|
||||
|
||||
bottomActionBar.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
|
||||
val bottomPadding = bottomActionBar.height + bottomPaddingExtra
|
||||
ViewUtil.setPaddingBottom(recyclerView, bottomPadding)
|
||||
|
||||
recyclerView.scrollBy(0, -(bottomPadding - additionalScrollOffset))
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BecomingGoneAnimationListener : Listener<Boolean> {
|
||||
override fun onSuccess(result: Boolean) {
|
||||
val scrollOffset = recyclerView.paddingBottom - additionalScrollOffset
|
||||
callback.onBottomActionBarVisibilityChanged(View.GONE)
|
||||
ViewUtil.setPaddingBottom(recyclerView, paddingBottom)
|
||||
|
||||
recyclerView.doOnPreDraw {
|
||||
recyclerView.scrollBy(0, scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(e: ExecutionException?) = Unit
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onBottomActionBarVisibilityChanged(visibility: Int)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user