Compare commits

..

132 Commits

Author SHA1 Message Date
Nicholas
36a8c4d8ba Bump version to 6.16.1 2023-03-30 18:33:53 -04:00
Nicholas
25f0427585 Updated baseline profile. 2023-03-30 18:33:00 -04:00
Nicholas
5a501f4815 Updated language translations. 2023-03-30 18:27:30 -04:00
Greyson Parrelli
de0a37d356 Fix possible dangling thread records. 2023-03-30 17:17:10 -04:00
Clark
5c65d5435c Fix rtl hiding conversation title. 2023-03-30 16:29:08 -04:00
Greyson Parrelli
8d6a4c2888 Fix SKDM processing. 2023-03-30 15:48:53 -04:00
Nicholas
b4a7ffdc12 Bump version to 6.16.0 2023-03-29 14:18:55 -04:00
Nicholas
5dd10f6fcc Updated baseline profile. 2023-03-29 14:16:21 -04:00
Nicholas
e76b5007e0 Updated language translations. 2023-03-29 14:13:51 -04:00
Nicholas Tinsley
16e8f9633e fixup! Add benchmark for conversation open. 2023-03-29 12:44:27 -04:00
Clark Chen
cb4a45fea3 Fix empty name crash when fetching first alpha recipient row. 2023-03-29 10:55:00 -04:00
Cody Henthorne
0017b7af26 Ignore contact joined message when determining if we should apply universal disappearing messages. 2023-03-28 16:06:23 -04:00
Greyson Parrelli
5f645193e4 Create Buttons.ActionButton component. 2023-03-28 16:02:45 -04:00
Cody Henthorne
607a06d379 Enable scheduled backups regardless of API version. 2023-03-28 09:24:11 -04:00
Cody Henthorne
149955e07a Fix crash when searching and lowercase snippet differs from input snippet. 2023-03-27 21:16:36 -04:00
Greyson Parrelli
80b9e4e7ae Updated username education icons. 2023-03-27 13:05:38 -04:00
Greyson Parrelli
f02ac86e45 Updated strings for username education fragment. 2023-03-27 11:07:43 -04:00
elena
45e96f0efe Show character count when writing a payment note.
Close #12616
2023-03-27 09:47:30 -04:00
Cody Henthorne
06894d6a7e Fix formatting on long text messages. 2023-03-24 17:15:02 -04:00
Greyson Parrelli
b67dfe10d4 Add some accessibility labels for the camera screen. 2023-03-24 16:44:05 -04:00
Greyson Parrelli
b9b6a57e2c Fix possible username conflict in storage update. 2023-03-24 16:32:38 -04:00
Cody Henthorne
ba2d005b2a Fix search result styling for formatting and query highlighting. 2023-03-24 15:49:27 -04:00
Cody Henthorne
f53679f24a Fix spoiler rendering in quotes. 2023-03-24 15:49:27 -04:00
Cody Henthorne
7eb00e41a2 Fix rendering of links and mentions covered by spoilers. 2023-03-24 15:49:26 -04:00
Jim Gustafson
168e37c3fc Update to RingRTC v2.26.1 2023-03-24 15:49:26 -04:00
Clark
98438ff8e4 Update async layout inflater to fix AppCompat views. 2023-03-24 15:49:26 -04:00
Clark
d6a9ed1a8d Add setting for requesting user account data. 2023-03-24 15:49:26 -04:00
Jim Gustafson
b194c0e84b Update to RingRTC v2.26.0 2023-03-24 15:49:26 -04:00
Nicholas
ed67e7ac04 Ignore smartwatch as possible headset. 2023-03-24 15:49:26 -04:00
Cody Henthorne
43cd647036 Add spoiler format style. 2023-03-24 15:49:26 -04:00
Greyson Parrelli
5d6889786c Bump version to 6.15.3 2023-03-24 14:39:52 -04:00
Greyson Parrelli
53d4e5c4d1 Updated language translations. 2023-03-24 14:39:52 -04:00
Greyson Parrelli
87918da943 Shorten lifespan of buffered store. 2023-03-24 14:39:51 -04:00
Greyson Parrelli
5914a4d1cf Improved logging around backup export. 2023-03-24 14:39:51 -04:00
Alex Hart
351baa4135 Update content descriptions for call toggles. 2023-03-24 14:39:51 -04:00
Alex Hart
1a71e1a5ae Fix cases where first letter is not an integer or character. 2023-03-24 14:39:51 -04:00
Alex Hart
3ce68a7df8 Allow toggle to be manually slid on preference screens. 2023-03-24 14:39:51 -04:00
Alex Hart
e83c2f1e05 Fix bottom row overlap in group creation / add activities. 2023-03-24 09:47:54 -03:00
Alex Hart
684e53402e Close keyboard after successful profile save. 2023-03-24 09:40:59 -03:00
Greyson Parrelli
db1853f775 Fix timestamp logs on call messages. 2023-03-23 18:58:49 -04:00
Greyson Parrelli
aad835323b Bump version to 6.15.2 2023-03-23 18:22:09 -04:00
Greyson Parrelli
d6f6633c73 Updated language translations. 2023-03-23 18:22:09 -04:00
Greyson Parrelli
76984ab042 Improve logging around message sending.
There were some message types where we didn't log the timestamp.
The timestamp is important for debugging issues (let's us locate an
error more precisely in the logs).
2023-03-23 18:08:39 -04:00
Greyson Parrelli
d58c4ef439 Unify locks in protocol stores. 2023-03-23 15:37:45 -04:00
Alex Hart
2763cfe6f4 Reintroduce labels for incoming call screen. 2023-03-23 16:10:25 -03:00
Greyson Parrelli
454e9a99fc Fix possible NPE in ConversationListFragment. 2023-03-23 14:41:40 -04:00
Alex Hart
aeb250cae1 Fix ISK minimum currency precision. 2023-03-23 14:41:25 -03:00
Greyson Parrelli
34367b4e70 Bump version to 6.15.1 2023-03-23 13:34:32 -04:00
Greyson Parrelli
451537d320 Updated language translations. 2023-03-23 13:34:32 -04:00
Greyson Parrelli
53d4825e12 Fully rebuild FTS after a backup restore. 2023-03-23 13:34:32 -04:00
Greyson Parrelli
24ee4a869f Fix empty state incorrectly showing in search. 2023-03-23 13:32:51 -04:00
Alex Hart
6ae3fb49e0 Add transitions to call state pill. 2023-03-23 13:32:51 -04:00
Alex Hart
8f9713a2c0 Add new call toast and remove call button labels. 2023-03-23 13:32:51 -04:00
Alex Hart
7a2ad37333 Add proper tinting to new chat icons. 2023-03-23 13:32:51 -04:00
Alex Hart
2509d1be73 Fix text alignment and padding on onboarding cards. 2023-03-23 13:32:51 -04:00
Alex Hart
19f4073068 Fix listener behavior for manual directory refresh invocation. 2023-03-23 13:32:51 -04:00
Alex Hart
fd612525a1 Fix touch interactions with MaterialSwitch in preferences. 2023-03-23 13:32:39 -04:00
Greyson Parrelli
631b428a84 Fix log statement.
We were printing out the envelope deviceId, which isn't populated with
sealed sender and was always 0. Should have used the one from the
decryption result.
2023-03-23 13:26:06 -04:00
Greyson Parrelli
09cd581cf4 Bump version to 6.15.0 2023-03-22 14:30:28 -04:00
Greyson Parrelli
fc1ea458f7 Updated language translations. 2023-03-22 14:28:36 -04:00
Clark
247edce7b0 Catch exceptions in share repository for blob provider IO exceptions. 2023-03-22 14:28:36 -04:00
Alex Hart
57a2a32c71 Update call button iconography and colours. 2023-03-22 14:28:36 -04:00
Clark
d9c1ecab9b Fix precaching of conversation list items. 2023-03-22 14:28:36 -04:00
Alex Hart
c70f1f5d75 Fix call screen transition. 2023-03-22 14:28:36 -04:00
Alex Hart
c26cc56f20 Fix bottom bar state handling and active state when menu is open. 2023-03-22 14:28:36 -04:00
Nicholas Tinsley
ca21ab667a Force LTR layout direction on NumericKeyboardView. 2023-03-22 14:28:36 -04:00
Clark
e2ae0063a5 Fix send button disappearing for voice drafts. 2023-03-22 14:28:36 -04:00
Alex Hart
eb150d9a15 Update SwitchMaterial to the new MaterialSwitch. 2023-03-22 14:28:36 -04:00
Cody Henthorne
ee48e6c347 Add sync message handling and stop formatting behavior. 2023-03-22 14:28:36 -04:00
Nicholas
cedf512726 Fix PanicKit for PIN lock.
Fixes #12816.
2023-03-22 14:28:10 -04:00
Clark
2256c8591a Add special audio recording sample rate for Xiaomi Mi 9T. 2023-03-22 14:28:10 -04:00
Alex Hart
1056adb591 Move distribution type operation into ConversationViewModel. 2023-03-22 14:28:10 -04:00
Alex Hart
53716019b6 Remove QuoteRestorationTask in favour of using DraftViewModel to resolve it. 2023-03-22 14:28:10 -04:00
Alex Hart
30f6faf3d7 Move mute handling into ConversationViewModel. 2023-03-22 14:28:10 -04:00
Alex Hart
2a43ffad4f Extract ConversationParentFragment Options Menu into a MenuProvider. 2023-03-22 14:28:10 -04:00
Alex Hart
f9ed5c4d03 Correct some icon tinting. 2023-03-22 14:28:10 -04:00
Cody Henthorne
25028e0e6f Add additional text formatting support. 2023-03-22 14:28:10 -04:00
Alex Hart
1c3636eedd Add undo-ability to call tab deletion. 2023-03-22 14:28:10 -04:00
Alex Hart
4d735d23b6 Remove unnecessary method calls in options menu code. 2023-03-22 14:28:10 -04:00
Greyson Parrelli
834d0a1cee Trigger an automatic session reset after failing to send a retry receipt. 2023-03-22 14:28:09 -04:00
Alex Hart
166e555d32 Kill two unused classes. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
7f963d7628 Keep protocol error logs longer. 2023-03-22 14:28:09 -04:00
Alex Hart
cebe600014 Update bottom bar to support just calls and chats. 2023-03-22 14:28:09 -04:00
Alex Hart
5c688289a5 Ensure we do not stage shared element transition view when opening media from a bubble. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
bf611f3a56 Fix potential NPE in SNC dialog. 2023-03-22 14:28:09 -04:00
Clark
150c42c590 Add notification for failed story messages. 2023-03-22 14:28:09 -04:00
Clark
069b707d9d Add dark mode for location picker. 2023-03-22 14:28:09 -04:00
Alex Hart
8c0d979abd Add call tab bottom bar. 2023-03-22 14:28:09 -04:00
Alex Hart
545f1fa5a4 Add call tab info screen. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
49a814abef Show blocked users as 'skipped' when sending to curated story list. 2023-03-22 14:28:09 -04:00
Clark
17fc0dc0a1 Add indicator and story ring for stories in chat selection. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
7c8de901f1 Store Job data as bytes. 2023-03-22 14:28:09 -04:00
Alex Hart
b5af581205 Set proper filter labeling on call tab. 2023-03-22 14:28:09 -04:00
Alex Hart
de73744432 Add new symbols for call tab. 2023-03-22 14:28:09 -04:00
Alex Hart
ce3770a0fb Add new call screen for calls tab. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
1210b2af0f Some additional decryption perf improvements. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
c6861f1778 Add support for the ManifestRecord.sourceDevice field. 2023-03-22 14:28:09 -04:00
Clark
906dd5cb40 Drop link preview thumbnail from forward if URI isn't present. 2023-03-22 14:27:59 -04:00
Clark
97b349b0de Add benchmark for conversation open. 2023-03-20 17:39:09 -04:00
Clark
f3b830ae20 Fix dark mode for compose bottom sheets. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
7d7e6e5013 Update SQLCipher to 4.5.3-FTS-S3 2023-03-20 17:39:09 -04:00
Alex Hart
8ca596580c Add info action wiring in calls tab. 2023-03-20 17:39:09 -04:00
Alex Hart
7521520b26 Ensure scrolling properly highlights action bar in calls tab. 2023-03-20 17:39:09 -04:00
Alex Hart
18554170f2 Update call tab to display unread missed call count. 2023-03-20 17:39:09 -04:00
Alex Hart
cd5a3768eb Fix back handling between tabs. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
cf64f06c36 Add a new test case for recipient merging. 2023-03-20 17:39:09 -04:00
Alex Hart
88de0f21e7 Add initial implementation of calls tab behind a feature flag. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
d1373d2767 Remove queue drained constraint from receipt jobs. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
baece9823b Remove log when enqueuing job within a transaction.
Found the bug I put the logging in for, and now this log happens way to
much after the decryption batching.
2023-03-20 17:39:09 -04:00
Greyson Parrelli
e18b2d263c Fix rendering of story replies in quote thread view. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
d12830cb66 Add language support for Uyghur. 2023-03-20 17:39:09 -04:00
Cody Henthorne
59141bc6a4 Improve delete thread performance. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
431e366e76 Add in possible recovery for DB error handler.
A bad FTS index can result in the corruption handler being triggered.
We can attempt to rebuild it to see if that helps.
2023-03-20 17:39:09 -04:00
Nicholas
66cb2a04c3 Rename properties of AccountAttributes. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
90cc672c37 Convert MessageTable to kotlin. 2023-03-20 17:39:09 -04:00
Clark
c2a76c4313 Convert ConversationTitleView to a ConstraintLayout. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
ee685936c5 Updated MessageProcessingPerformanceTest to use websocket injection. 2023-03-20 17:39:09 -04:00
Alex Hart
a7bca89889 Perform username deletion if no local name is set. 2023-03-20 17:39:09 -04:00
Clark
39f5aebbec Add support for scheduling media to multiple contacts. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
35571e7ab2 Added another RecipientTable.getAndPossiblyMerge test case. 2023-03-20 17:39:09 -04:00
Clark
ed2d6ea903 Only setup mock data once for baseline profiles and benchmarks. 2023-03-20 17:39:09 -04:00
Alex Hart
e1e117ce73 Increase logging around username synchronization. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
894095414a Perform message decryptions in batches. 2023-03-20 17:39:09 -04:00
Clark
04baa7925f Add support for baseline profiles. 2023-03-20 17:39:08 -04:00
Clark
79a062c838 Introduce thread priorities for threads and handlerthreads. 2023-03-20 17:39:08 -04:00
Greyson Parrelli
2cef06cd6e Bump version to 6.14.5 2023-03-20 17:37:51 -04:00
Greyson Parrelli
af4b98f424 Updated language translations. 2023-03-20 17:37:51 -04:00
Greyson Parrelli
cd66ba60e3 Disable view precaching of chat list to fix selection checkmark bug. 2023-03-20 17:37:49 -04:00
Greyson Parrelli
2d2a1049a4 Make onboarding card close button background borderless. 2023-03-20 17:37:24 -04:00
Greyson Parrelli
03aa6a1d61 Fix potential crash when starting IncomingMessageObserver service. 2023-03-20 17:37:24 -04:00
Greyson Parrelli
6c6d4e801f Fix crash when starting multiple audio records. 2023-03-20 17:37:24 -04:00
Cody Henthorne
a6d7b0c7bf Fix crash in multishare flow. 2023-03-20 17:37:24 -04:00
648 changed files with 70576 additions and 10520 deletions

View File

@@ -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'

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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}")
}

View File

@@ -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>()

View File

@@ -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)
}

View 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>

View File

@@ -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)
}
}
}

View File

@@ -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>) {
}
}
}

View File

@@ -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
}
}
}

View 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
}
}

View File

@@ -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))
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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
)
}

View File

@@ -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() {

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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()
}
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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()) {

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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() {

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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>
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}

View File

@@ -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
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.
*/

View File

@@ -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)
}
}
}
}
}

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}
}

View File

@@ -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?) {

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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)
)
}
}

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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() }
}
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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 -> {
}
}

View File

@@ -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) {

View File

@@ -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
) {

View File

@@ -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.")
}
)

View File

@@ -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)
}
}
}

View File

@@ -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<*>>()

View File

@@ -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()
}
}
}
}

View File

@@ -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)

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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);

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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()
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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()
}
}

View File

@@ -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) {

View File

@@ -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));
}
}

View File

@@ -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";

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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