Compare commits

..

109 Commits

Author SHA1 Message Date
Nicholas Tinsley
e84c6187b9 Bump version to 6.10.5 2023-02-01 16:59:54 -05:00
Nicholas Tinsley
b5d52db57c Add logging for audio recorder exceptions. 2023-02-01 11:41:30 -05:00
Alex Hart
f320cf8833 Add factory for story privacy view model. 2023-02-01 12:22:55 -04:00
Cody Henthorne
c76ca957e1 Prevent keyboard from closing immediately after opening. 2023-02-01 10:35:42 -05:00
Cody Henthorne
56354f6aae Fix memory leak of schedule message observers. 2023-02-01 10:22:17 -05:00
Nicholas Tinsley
2bf84a5f77 Fix country code dropdown during registration. 2023-02-01 09:54:44 -05:00
Nicholas Tinsley
8b23d9a6c4 Bump version to 6.10.4 2023-01-31 17:20:05 -05:00
Nicholas Tinsley
2cae3ddf04 Updated language translations. 2023-01-31 17:20:05 -05:00
Greyson Parrelli
670b6c4c56 Revert "Upgrade to Glide 4.14.2"
This reverts commit 3ee889cb79.
2023-01-31 16:15:33 -05:00
Alex Hart
eceed641bf Fix leak in recording session. 2023-01-31 16:15:33 -05:00
Nicholas
5febe6490c Null check Media URI in save task. 2023-01-31 16:15:33 -05:00
Nicholas
dca47e4cb5 Strip mention Spans out of media captions. 2023-01-31 16:15:33 -05:00
Nicholas
c3bcba6380 Design improvements for registration flow. 2023-01-31 16:15:33 -05:00
Alex Hart
cb01692a50 Fix localization of note to self string in search. 2023-01-31 10:07:50 -04:00
Alex Hart
e90074ffef Fix issue with bottom sheets. 2023-01-31 10:00:25 -04:00
Alex Hart
691520bc75 Clean out dead code from contact search. 2023-01-30 12:52:27 -04:00
Greyson Parrelli
e6de06be6f Bump version to 6.10.3 2023-01-30 10:40:13 -05:00
Greyson Parrelli
a77079ac81 Updated language translations. 2023-01-30 10:39:52 -05:00
Greyson Parrelli
30b58fe5f4 Don't run FTS optimize job (for now). 2023-01-30 10:39:41 -05:00
Greyson Parrelli
7275b95b58 Bump version to 6.10.2 2023-01-27 17:42:22 -05:00
Greyson Parrelli
f04d46b4ed Updated language translations. 2023-01-27 17:42:22 -05:00
Greyson Parrelli
e12bbe943b Restore the 3-dot menu when creating a PIN. 2023-01-27 17:42:22 -05:00
Greyson Parrelli
7348224dc2 Prevent thread trimming from gumming up the database. 2023-01-27 17:42:22 -05:00
Cody Henthorne
30c33fdd77 Fix issues with scheduled messages and quotes.
- Tapping quote in schedule view will jump to message in chat
- Scheduling a quote will not make the quoted message render as "isQuoted"
- Scheduled quotes will not appear in the quoted message's sheet of replies
- Fixes an off-by-N where N = # of scheduled messages when calculating location for jumping to a message
2023-01-27 17:42:22 -05:00
Alex Hart
e7339af119 Add fix for group membership query. 2023-01-27 17:42:22 -05:00
Cody Henthorne
661fff7a0e Fix scheduled messages being sent out of order. 2023-01-27 17:42:22 -05:00
Alex Hart
c37bad0f7a Fix opening filter when swiping from within collapsingtoolbar. 2023-01-27 17:42:22 -05:00
Alex Hart
7f228fc0fd Do not display add to story if stories are disabled. 2023-01-27 17:42:22 -05:00
Cody Henthorne
14cd216668 Fix crash when delete scheduled message dialog is open and message sends. 2023-01-27 17:42:22 -05:00
Cody Henthorne
0cb0ef977c Add calendar icon to pick date and time item.
Co-authored-by: Sgn-32 <49990901+Sgn-32@users.noreply.github.com>
2023-01-27 17:42:22 -05:00
Cody Henthorne
1761529ce9 Disable schedule message for SMS. 2023-01-27 17:42:22 -05:00
Clark
a14fc82e83 Fix scheduled view once message looking weird. 2023-01-27 17:42:22 -05:00
Clark
b94f5501d9 Disable scheduling of voice note messages. 2023-01-27 17:42:21 -05:00
Clark
834283ba9b Hide scheduled messages bar with input panel. 2023-01-27 17:42:21 -05:00
Nicholas
23190a2f6e Fix NumericKeyboardView in RTL. 2023-01-27 11:06:04 -05:00
Alex Hart
04f4cd8edc Fix V172 Migration. 2023-01-27 10:20:44 -04:00
Greyson Parrelli
8f02e4e1f5 Bump version to 6.10.1 2023-01-26 20:25:28 -05:00
Greyson Parrelli
db81a5be04 Updated language translations. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
fe40e37da4 Fix full text search migration after table name change. 2023-01-26 20:25:28 -05:00
Alex Hart
22a4271dfb Rotate paypal recurring donations flag. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
1263b51e03 Patch random place where we forgot to update minSdk. 2023-01-26 20:25:28 -05:00
Nicholas
ca468047ef Adjust media caption height, fix rail visibility. 2023-01-26 20:25:28 -05:00
Clark
958c52a5b8 Force indexes for scheduled message queries. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
9b28585c59 Add foreign key dependency between reactions and messages. 2023-01-26 20:25:27 -05:00
Clark
c5c60b7214 Add permissions dialogs for scheduled messages. 2023-01-26 20:25:27 -05:00
Nicholas
31bcc2e2eb Finish MediaPreviewV2Activity when jumping to a message. 2023-01-26 20:25:27 -05:00
Cody Henthorne
71ecba17fc Fix crash when saving empty formatted text drafts. 2023-01-26 20:25:27 -05:00
Greyson Parrelli
afa5c68312 Periodically optimize the FTS index. 2023-01-26 20:25:27 -05:00
Clark
f3e715e069 Add support for scheduled message sends. 2023-01-26 20:25:27 -05:00
Alex Hart
df695f7611 Fix crash when trying to create new group story.
Adds INNER JOIN to threads table to allow access to date in ORDER BY
2023-01-26 20:25:27 -05:00
Greyson Parrelli
27e1bc0854 Bump version to 6.10.0 2023-01-25 17:11:34 -05:00
Greyson Parrelli
f4371b9e96 Updated language translations. 2023-01-25 17:08:53 -05:00
Cody Henthorne
e0633180ef Fix crash when trying to update a group call without an era id. 2023-01-25 17:02:41 -05:00
Alex Hart
32dd227ab6 Utilize left join instead of inner join when querying groups. 2023-01-25 17:02:41 -05:00
Cody Henthorne
0deed9d4d2 Fix notification sound not respecting notification volume.
Fix is immediate for general messages channel and for future custom channel creation
2023-01-25 17:02:41 -05:00
Cody Henthorne
cc490f4b73 Add text formatting send and receive support for conversations. 2023-01-25 17:02:41 -05:00
Cody Henthorne
aa2075c78f Attempt to fix view jitter when switching keyboards. 2023-01-25 17:02:41 -05:00
Alex Hart
b4a34599d7 Add support for message and thread results. 2023-01-25 17:02:41 -05:00
Nicholas
8dd1d3bdeb Allow user-selected backup time. 2023-01-25 17:02:41 -05:00
Greyson Parrelli
a7d9bd944b Insert session switchover events when appropriate. 2023-01-25 17:02:41 -05:00
Clark
7745ae62ea Add logging for voice note recording events. 2023-01-25 17:02:41 -05:00
Greyson Parrelli
6e5b4bbc15 Enable gradle configuration cache.
Android Studio told me to do this and that it would save me over 7
seconds. We'll see if it breaks anything :p
2023-01-25 17:02:41 -05:00
Clark
e3b38e6d38 Fix some thumbnail images not showing up in MediaGallery. 2023-01-25 17:02:41 -05:00
Clark
25aa4f39a3 Close input streams on failed resource decryption. 2023-01-25 17:02:41 -05:00
Cody Henthorne
17849e20bd Update group receipt table when sync'ing story sends to distribution lists. 2023-01-25 17:02:41 -05:00
Alex Hart
c022172ace Add group member results to contact search. 2023-01-25 17:02:41 -05:00
Nicholas
eaeeb08987 Display message text in Media Preview. 2023-01-25 17:02:41 -05:00
Alex Hart
1b7e4e047c Introduce ManyToMany table for group membership. 2023-01-24 14:18:28 -05:00
Clark
d635683303 Fix share intent not being cleared from recents. 2023-01-24 14:18:28 -05:00
Clark
4dcbbfdd63 Fix voice note draft not being generated on audio focus loss. 2023-01-24 14:18:28 -05:00
Nicholas
150bbf181d Redesign FTUX to use Material Design 3. 2023-01-24 14:18:28 -05:00
Alex Hart
0303467c91 Add PayPal decline code errors. 2023-01-24 14:18:28 -05:00
Nicholas
88da382a6f For calling purposes, categorize hearing aids as Bluetooth headsets. 2023-01-24 14:18:28 -05:00
Alex Hart
5d14166a27 Add support for arbitrary rows in contact search. 2023-01-24 14:18:28 -05:00
Jim Gustafson
d76d13f76c Update to RingRTC v2.23.1 2023-01-24 14:18:28 -05:00
Greyson Parrelli
ad4ec23875 Bump version to 6.9.2 2023-01-24 14:15:52 -05:00
Greyson Parrelli
61df2afc32 Updated language translations. 2023-01-24 14:15:52 -05:00
Nicholas Tinsley
1c6d2f7198 If enabled, don't unarchive muted group chats.
Fixes #12732
2023-01-24 14:13:42 -05:00
Cody Henthorne
df8f9761b2 Fix incorrect total sms export count. 2023-01-24 13:49:25 -05:00
Greyson Parrelli
657c5d2bce Bump version to 6.9.1 2023-01-20 18:12:37 -05:00
Greyson Parrelli
81324c6923 Updated language translations. 2023-01-20 18:12:17 -05:00
Greyson Parrelli
269a2e2990 Improve timer event generation from GV2 sync messages. 2023-01-20 17:58:17 -05:00
clark-signal
73b453b0d4 Fix re-used share intent when restarting task from recent activities. 2023-01-20 13:42:13 -05:00
Cody Henthorne
97604dc4c5 Bump version to 6.9.0 2023-01-19 13:44:21 -05:00
Cody Henthorne
8e1c05ed64 Updated language translations. 2023-01-19 13:38:09 -05:00
Nicholas
231b55a956 Don't show media controls on new pages. 2023-01-19 13:33:07 -05:00
Alex Hart
4fcdee9fa5 Rotate PayPal recurring donations feature flag. 2023-01-19 13:33:07 -05:00
Cody Henthorne
6e2e5e21cc Update copy and behavior of SMS phased removal flow. 2023-01-19 13:33:07 -05:00
Alex Hart
8f49323648 Extract adapter creation from ContactSearchMediator. 2023-01-19 13:33:07 -05:00
Greyson Parrelli
13f969b622 Fix an app migration.
Fixes #12730
2023-01-19 13:33:07 -05:00
Greyson Parrelli
518d9b3984 Update SQLCipher to 4.5.1-S1
This reverts commit d1894caea6.
2023-01-19 13:33:07 -05:00
Alex Hart
8e313f8387 Collapse KnownRecipient / Story into single model. 2023-01-19 13:33:07 -05:00
Nicholas
70c6e9e60f Store additional data that will allow us to reduce the number of verification SMSs. 2023-01-19 13:32:35 -05:00
Cody Henthorne
dcf8a82c37 Fix no snippet being shown for threads.
Snippet query wasn't updated to exclude SMS types after the conjuction
of the tables.
2023-01-19 13:32:35 -05:00
Alex Hart
f368e5b133 Suppress deselection error when opening gallery from chat. 2023-01-19 13:32:35 -05:00
Alex Hart
3ee889cb79 Upgrade to Glide 4.14.2 2023-01-17 14:30:48 -05:00
Greyson Parrelli
3e7dc79fe8 Remove unnecessary code now that minSdk is 21. 2023-01-17 14:30:48 -05:00
Greyson Parrelli
8cfd02aff2 Bump minSdk to 21. 2023-01-17 14:30:48 -05:00
Alex Hart
f36efc562e Fix improper filtering of unread conversations. 2023-01-17 14:30:48 -05:00
Sgn-32
67b6b109de Use ic_save_24_tinted instead of ic_download_24_tinted. 2023-01-17 14:30:48 -05:00
Alex Hart
8fd378db4e Fix issue where links do not render in stories if previews are off. 2023-01-17 14:30:48 -05:00
Cody Henthorne
760ace93d4 Enable PNI group invite processing. 2023-01-17 14:30:48 -05:00
Nicholas
125fd83afa Programmatically dismiss logged out notification on registration. 2023-01-17 14:30:48 -05:00
Alex Hart
7dcb598b66 Inline gift badge flag. 2023-01-17 14:30:48 -05:00
Alex Hart
4917e93d9f Update CameraX to 1.2.0 2023-01-17 14:30:48 -05:00
Alex Hart
1e9115a917 Increment compileSdkVersion to 33. 2023-01-17 14:30:48 -05:00
clark-signal
7af94f60ae Fix story linking from private story reply. 2023-01-17 14:30:48 -05:00
clark-signal
c3c8f8e7e6 Update icons across home screen and settings screen. 2023-01-17 14:30:48 -05:00
clark-signal
011c85c75b Fix "My subscription" dark mode overlapping background bubbles. 2023-01-17 14:30:47 -05:00
527 changed files with 16620 additions and 5219 deletions

View File

@@ -48,8 +48,6 @@
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>

View File

@@ -52,8 +52,8 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 1196
def canonicalVersionName = "6.8.3"
def canonicalVersionCode = 1205
def canonicalVersionName = "6.10.5"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -106,6 +106,11 @@ android {
}
}
dependenciesInfo {
includeInBundle false
includeInApk false
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'

View File

@@ -64,7 +64,7 @@ class SafetyNumberChangeDialogPreviewer {
SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations(
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId))
destinations = listOf(ContactSearchKey.RecipientSearchKey(myStoryRecipientId, true))
)
.show(conversationActivity.supportFragmentManager)
}

View File

@@ -0,0 +1,225 @@
package org.thoughtcrime.securesms.database
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.delete
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.security.SecureRandom
import kotlin.random.Random
class GroupTableTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var groupTable: GroupTable
@Before
fun setUp() {
groupTable = SignalDatabase.groups
groupTable.writableDatabase.delete(GroupTable.TABLE_NAME).run()
groupTable.writableDatabase.delete(GroupTable.MembershipTable.TABLE_NAME).run()
}
@Test
fun whenICreateGroupV2_thenIExpectMemberRowsPopulated() {
val groupId = insertPushGroup()
//language=sql
val members: List<RecipientId> = groupTable.writableDatabase.query(
"""
SELECT ${GroupTable.MembershipTable.RECIPIENT_ID}
FROM ${GroupTable.MembershipTable.TABLE_NAME}
WHERE ${GroupTable.MembershipTable.GROUP_ID} = "${groupId.serialize()}"
""".trimIndent()
).readToList {
RecipientId.from(it.requireLong(GroupTable.RECIPIENT_ID))
}
assertEquals(2, members.size)
}
@Test
fun givenAGroupV2_whenIGetGroupsContainingMember_thenIExpectGroup() {
val groupId = insertPushGroup()
insertThread(groupId)
val groups = groupTable.getGroupsContainingMember(harness.others[0], false)
assertEquals(1, groups.size)
assertEquals(groupId, groups[0].id)
}
@Test
fun givenAnMmsGroup_whenIGetMembers_thenIExpectAllMembers() {
val groupId = insertMmsGroup()
val groups = groupTable.getGroupMemberIds(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
assertEquals(2, groups.size)
}
@Test
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.queryGroupsByMembership(
setOf(harness.self.id, harness.others[1]),
includeInactive = false,
excludeV1 = false,
excludeMms = false
)
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.getGroups()
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenAGroup_whenIGetGroup_thenIExpectGroup() {
val v2Group = insertPushGroup()
insertThread(v2Group)
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, harness.others[0]), groupRecord.members.toSet())
}
@Test
fun givenAGroupAndARemap_whenIGetGroup_thenIExpectRemap() {
val v2Group = insertPushGroup()
insertThread(v2Group)
groupTable.writableDatabase.withinTransaction {
RemappedRecords.getInstance().addRecipient(harness.others[0], harness.others[1])
}
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(groupRecord.members.toSet(), setOf(harness.self.id, harness.others[1]))
}
@Test
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertTrue(actual)
}
@Test
fun givenAGroupAndMember_whenIRemove_thenIExpectNotAMember() {
val v2Group = insertPushGroup()
groupTable.remove(v2Group, harness.others[0])
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertFalse(actual)
}
@Test
fun givenAGroupAndNonMember_whenIIsCurrentMember_thenIExpectFalse() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[1])
assertFalse(actual)
}
@Test
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
val v2Group = insertPushGroup()
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
val groupRecord = groupTable.getGroup(v2Group)
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
}
@Test
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
val other = insertMmsGroup(members + listOf(harness.others[1]))
val mmsGroup = insertMmsGroup(members)
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
assertNotEquals(other, actual)
assertEquals(mmsGroup, actual)
}
@Test
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
val g1 = insertPushGroup(listOf())
val g2 = insertPushGroup(listOf())
val gr1 = groupTable.getGroup(g1)
val gr2 = groupTable.getGroup(g2)
assertEquals(g1, gr1.get().id)
assertEquals(g2, gr2.get().id)
}
private fun insertThread(groupId: GroupId): Long {
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
}
private fun insertMmsGroup(members: List<RecipientId> = listOf(harness.self.id, harness.others[0])): GroupId {
val id = GroupId.createMms(SecureRandom())
groupTable.create(
id,
null,
members.apply {
println("Creating a group with ${members.size} members")
}
)
return id
}
private fun insertPushGroup(
members: List<DecryptedMember> = listOf(
DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
)
): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members)
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)
}
}

View File

@@ -1,17 +1,21 @@
package org.thoughtcrime.securesms.database
import android.database.Cursor
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
@@ -23,6 +27,8 @@ import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -32,6 +38,9 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
@@ -46,6 +55,7 @@ class RecipientTableTest_getAndPossiblyMerge {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
@@ -115,24 +125,40 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, null, ACI_B)
}
test("e164 and pni matches, all provided, new aci") {
test("e164 and pni matches, all provided, new aci, no pni session") {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("e164 and pni matches, all provided, new aci, existing pni session") {
given(E164_A, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("e164 and aci matches, all provided, new pni") {
given(E164_A, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, new e164 and aci") {
test("pni matches, all provided, new e164 and aci, no pni session") {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, new e164 and aci, existing pni session") {
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni and aci matches, all provided, new e164") {
given(null, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
@@ -172,20 +198,42 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
}
test("e164 and pni matches, all provided, no existing session") {
test("e164 matches, e164 and pni provided, pni changes, existing pni session") {
given(E164_A, PNI_B, null, pniSession = true)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expectSessionSwitchoverEvent(E164_A)
}
test("e164 and pni matches, all provided, no pni session") {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, no existing session") {
test("e164 and pni matches, all provided, existing pni session") {
given(E164_A, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni matches, all provided, no pni session") {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test("pni matches, all provided, existing pni session") {
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni matches, no existing pni session, changes number") {
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
@@ -194,8 +242,15 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test("pni matches, existing pni session, changes number") {
given(E164_B, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_B)
expectChangeNumberEvent()
}
test("pni and aci matches, change number") {
given(E164_B, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
@@ -220,7 +275,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164+pni & e164+pni, no aci provided, no sessions") {
test("steal, e164+pni & e164+pni, no aci provided, no pni session") {
given(E164_A, PNI_B, null)
given(E164_B, PNI_A, null)
@@ -230,6 +285,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_B, null, null)
}
test("steal, e164+pni & e164+pni, no aci provided, existing pni session") {
given(E164_A, PNI_B, null, pniSession = true)
given(E164_B, PNI_A, null) // TODO How to handle if this user had a session? They just end up losing the PNI, meaning it would become unregistered, but it could register again later with a different PNI?
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expectSessionSwitchoverEvent(E164_A)
}
test("steal, e164+pni & aci, e164 record has separate e164") {
given(E164_B, PNI_A, null)
given(null, null, ACI_A)
@@ -252,6 +319,19 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164 & pni+e164, no aci provided") {
val id1 = given(E164_A, null, null)
val id2 = given(E164_B, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expectSessionSwitchoverEvent(id1, E164_A)
expectSessionSwitchoverEvent(id2, E164_B)
}
test("merge, e164 & pni & aci, all provided") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -262,6 +342,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & pni, no aci provided") {
@@ -272,9 +354,11 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided but no aci record") {
test("merge, e164 & pni, aci provided, no pni session") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -282,16 +366,33 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni+e164, no aci provided") {
test("merge, e164 & pni, aci provided, no pni session") {
given(E164_A, null, null)
given(E164_B, PNI_A, null)
given(null, PNI_A, null)
process(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session") {
given(E164_A, null, null)
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164+pni & pni, no aci provided") {
@@ -302,6 +403,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164+pni & aci") {
@@ -312,6 +415,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+pni+aci, change number") {
@@ -324,6 +429,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+aci, change number") {
@@ -336,6 +442,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & aci") {
@@ -346,6 +453,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expect(E164_A, null, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & e164+aci, change number") {
@@ -358,6 +467,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, null, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
@@ -539,11 +650,11 @@ class RecipientTableTest_getAndPossiblyMerge {
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
return SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionTable.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionTable.THREAD_ID)
MentionModel(
recipientId = RecipientId.from(cursor.requireLong(MentionTable.RECIPIENT_ID)),
threadId = cursor.requireLong(MentionTable.THREAD_ID)
)
}
}
@@ -577,6 +688,14 @@ class RecipientTableTest_getAndPossiblyMerge {
if (!test.changeNumberExpected) {
test.expectNoChangeNumberEvent()
}
if (!test.threadMergeExpected) {
test.expectNoThreadMergeEvent()
}
if (!test.sessionSwitchoverExpected) {
test.expectNoSessionSwitchoverEvent()
}
} catch (e: Throwable) {
if (e.javaClass != exception) {
val error = java.lang.AssertionError("[$name] ${e.message}")
@@ -594,6 +713,8 @@ class RecipientTableTest_getAndPossiblyMerge {
private lateinit var outputRecipientId: RecipientId
var changeNumberExpected = false
var threadMergeExpected = false
var sessionSwitchoverExpected = false
init {
// Need to delete these first to prevent foreign key crash
@@ -616,9 +737,8 @@ class RecipientTableTest_getAndPossiblyMerge {
pni: PNI?,
aci: ACI?,
createThread: Boolean = true,
sms: List<String> = emptyList(),
mms: List<String> = emptyList()
) {
pniSession: Boolean = false
): RecipientId {
val id = insert(e164, pni, aci)
generatedIds += id
if (createThread) {
@@ -626,6 +746,16 @@ class RecipientTableTest_getAndPossiblyMerge {
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
}
if (pniSession) {
if (pni == null) {
throw IllegalArgumentException("pniSession = true but pni is null!")
}
SignalDatabase.sessions.store(pni, SignalProtocolAddress(pni.toString(), 1), SessionRecord())
}
return id
}
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false) {
@@ -676,6 +806,32 @@ class RecipientTableTest_getAndPossiblyMerge {
changeNumberExpected = false
}
fun expectSessionSwitchoverEvent(e164: String) {
expectSessionSwitchoverEvent(outputRecipientId, e164)
}
fun expectSessionSwitchoverEvent(recipientId: RecipientId, e164: String) {
val event: SessionSwitchoverEvent? = getLatestSessionSwitchoverEvent(recipientId)
assertNotNull(event)
assertEquals(e164, event!!.e164)
sessionSwitchoverExpected = true
}
fun expectNoSessionSwitchoverEvent() {
assertNull(getLatestSessionSwitchoverEvent(outputRecipientId))
}
fun expectThreadMergeEvent(previousE164: String) {
val event: ThreadMergeEvent? = getLatestThreadMergeEvent(outputRecipientId)
assertNotNull(event)
assertEquals(previousE164, event!!.previousE164)
threadMergeExpected = true
}
fun expectNoThreadMergeEvent() {
assertNull(getLatestThreadMergeEvent(outputRecipientId))
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val serviceIdString: String? = (aci ?: pni)?.toString()
val pniString: String? = pni?.toString()
@@ -746,6 +902,42 @@ class RecipientTableTest_getAndPossiblyMerge {
}
}
private fun getLatestThreadMergeEvent(recipientId: RecipientId): ThreadMergeEvent? {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.THREAD_MERGE_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
ThreadMergeEvent.parseFrom(bytes)
} else {
null
}
}
}
private fun getLatestSessionSwitchoverEvent(recipientId: RecipientId): SessionSwitchoverEvent? {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.SESSION_SWITCHOVER_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
SessionSwitchoverEvent.parseFrom(bytes)
} else {
null
}
}
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))

View File

@@ -111,7 +111,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SetAci(result.id, ACI_A)
)
@@ -131,9 +131,9 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
@@ -151,7 +151,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
@@ -170,7 +170,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetAci(result.id, ACI_A)
)
),
@@ -189,9 +189,9 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
@@ -209,7 +209,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
@@ -228,7 +228,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A)
)
@@ -248,10 +248,10 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
@@ -269,7 +269,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.ChangeNumberInsert(
@@ -277,7 +277,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
oldE164 = E164_B,
newE164 = E164_A
),
PnpOperation.SessionSwitchoverInsert(result.id)
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
@@ -295,7 +295,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
)
),
@@ -314,7 +314,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
@@ -338,7 +338,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A)
)
@@ -358,7 +358,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.ChangeNumberInsert(
@@ -387,7 +387,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.thirdId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
@@ -416,7 +416,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
@@ -441,7 +441,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
@@ -470,7 +470,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(
recipientId = result.firstId,
@@ -496,7 +496,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.Merge(
primaryId = result.firstId,
@@ -522,7 +522,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A)
)
@@ -545,11 +545,11 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.secondId),
PnpOperation.SessionSwitchoverInsert(result.firstId)
PnpOperation.SessionSwitchoverInsert(result.secondId, E164_A),
PnpOperation.SessionSwitchoverInsert(result.firstId, E164_A)
)
),
result.changeSet
@@ -570,7 +570,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
@@ -595,7 +595,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
@@ -625,7 +625,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
@@ -660,7 +660,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
@@ -692,7 +692,7 @@ class RecipientTableTest_processPnpTupleToChangeSet {
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
operations = linkedSetOf(
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,

View File

@@ -27,7 +27,7 @@ class SafetyNumberBottomSheetRepositoryTest {
@Test
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
val recipients = harness.others
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey(it, false) }
val result = subjectUnderTest.getBuckets(recipients, destinations).test()
@@ -42,7 +42,7 @@ class SafetyNumberBottomSheetRepositoryTest {
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
// GIVEN
val recipients = harness.others
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey(it, false) }
// WHEN
val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
@@ -59,7 +59,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
// WHEN
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
@@ -82,7 +82,7 @@ class SafetyNumberBottomSheetRepositoryTest {
val distributionListMembers = harness.others.take(5)
val toRemove = distributionListMembers.last()
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()
@@ -108,7 +108,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.util;
/**
* A class that allows us to inject feature flags during tests.
*/
public final class FeatureFlagsAccessor {
public static void forceValue(String key, Object value) {
FeatureFlags.FORCED_VALUES.put(FeatureFlags.PHONE_NUMBER_PRIVACY, true);
}
}

View File

@@ -98,9 +98,10 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
tools:replace="android:allowBackup"
android:resizeableActivity="true"
android:allowBackup="false"
android:fullBackupOnly="false"
android:allowBackup="true"
android:backupAgent=".absbackup.SignalBackupAgent"
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
@@ -814,6 +815,8 @@
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
<receiver android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm" />
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
<receiver android:name=".service.TrimThreadsByDateManager$TrimThreadsByDateAlarm" />

View File

@@ -4,8 +4,6 @@ import android.content.Context;
import android.net.Uri;
import android.provider.DocumentsContract;
import androidx.annotation.RequiresApi;
import org.signal.core.util.logging.Log;
/**
@@ -22,7 +20,6 @@ public class DocumentFileHelper {
*
* @return true if rename successful
*/
@RequiresApi(21)
public static boolean renameTo(Context context, DocumentFile documentFile, String displayName) {
if (documentFile instanceof TreeDocumentFile) {
Log.d(TAG, "Renaming document directly");

View File

@@ -104,6 +104,7 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.CompletableObserver;
@@ -168,11 +169,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("lifecycle-observer", () -> ApplicationDependencies.getAppForegroundObserver().addListener(this))
.addBlocking("message-retriever", this::initializeMessageRetrieval)
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
.addBlocking("vector-compat", () -> {
if (Build.VERSION.SDK_INT < 21) {
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
}
})
.addBlocking("proxy-init", () -> {
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
@@ -186,6 +182,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeScheduledMessageManager)
.addNonBlocking(this::initializeFcmCheck)
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
.addNonBlocking(this::initializePeriodicTasks)
@@ -394,6 +391,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary();
}
private void initializeScheduledMessageManager() {
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
}
private void initializeTrimThreadsByDateManager() {
KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration();
if (keepMessagesDuration != KeepMessagesDuration.FOREVER) {
@@ -415,7 +416,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
CallManager.initialize(this, new RingRtcLogger());
CallManager.initialize(this, new RingRtcLogger(), Collections.emptyMap());
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);
}

View File

@@ -72,12 +72,10 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
setTheme(R.style.TextSecure_MediaPreview);
setContentView(R.layout.contact_photo_preview_activity);
if (Build.VERSION.SDK_INT >= 21) {
postponeEnterTransition();
TransitionInflater inflater = TransitionInflater.from(this);
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
}
postponeEnterTransition();
TransitionInflater inflater = TransitionInflater.from(this);
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
Toolbar toolbar = findViewById(R.id.toolbar);
EmojiTextView title = findViewById(R.id.title);
@@ -122,9 +120,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
avatar.setImageDrawable(RoundedBitmapDrawableFactory.create(resources, resource));
if (Build.VERSION.SDK_INT >= 21) {
startPostponedEnterTransition();
}
startPostponedEnterTransition();
}
@Override

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import java.util.Objects;
@@ -42,7 +43,7 @@ public abstract class BaseActivity extends AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
initializeScreenshotSecurity();
WindowUtil.initializeScreenshotSecurity(this, getWindow());
}
@Override
@@ -64,14 +65,6 @@ public abstract class BaseActivity extends AppCompatActivity {
super.onDestroy();
}
private void initializeScreenshotSecurity() {
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
}
}
protected void startActivitySceneTransition(Intent intent, View sharedView, String transitionName) {
Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this, sharedView, transitionName)
.toBundle();

View File

@@ -106,6 +106,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInviteToSignalClicked();
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
@@ -44,7 +43,7 @@ class BiometricDeviceAuthentication(
Log.i(TAG, "Skipping show system biometric or device lock dialog unless forced")
}
true
} else if (Build.VERSION.SDK_INT >= 21) {
} else {
if (force) {
Log.i(TAG, "firing intent...")
showConfirmDeviceCredentialIntent()
@@ -52,9 +51,6 @@ class BiometricDeviceAuthentication(
Log.i(TAG, "Skipping firing intent unless forced")
}
true
} else {
Log.w(TAG, "Not compatible...")
false
}
}
@@ -65,7 +61,6 @@ class BiometricDeviceAuthentication(
class BiometricDeviceLockContract : ActivityResultContract<String, Int>() {
@RequiresApi(api = 21)
override fun createIntent(context: Context, input: String): Intent {
val keyguardManager = ServiceUtil.getKeyguardManager(context)
return keyguardManager.createConfirmDeviceCredentialIntent(input, "")

View File

@@ -140,27 +140,18 @@ public class DeviceActivity extends PassphraseRequiredActivity
Uri uri = Uri.parse(data);
deviceLinkFragment.setLinkClickedListener(uri, DeviceActivity.this);
if (Build.VERSION.SDK_INT >= 21) {
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
getSupportFragmentManager().beginTransaction()
.addToBackStack(null)
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
.replace(R.id.fragment_container, deviceLinkFragment)
.commit();
getSupportFragmentManager().beginTransaction()
.addToBackStack(null)
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
.replace(R.id.fragment_container, deviceLinkFragment)
.commit();
} else {
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_bottom, R.anim.slide_to_bottom,
R.anim.slide_from_bottom, R.anim.slide_to_bottom)
.replace(R.id.fragment_container, deviceLinkFragment)
.addToBackStack(null)
.commit();
}
});
}

View File

@@ -41,22 +41,19 @@ public class DeviceAddFragment extends LoggingFragment {
this.devicesImage = container.findViewById(R.id.devices);
ViewCompat.setTransitionName(devicesImage, "devices");
if (Build.VERSION.SDK_INT >= 21) {
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@TargetApi(21)
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom)
{
v.removeOnLayoutChangeListener(this);
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom)
{
v.removeOnLayoutChangeListener(this);
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
reveal.setInterpolator(new DecelerateInterpolator(2f));
reveal.setDuration(800);
reveal.start();
}
});
}
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
reveal.setInterpolator(new DecelerateInterpolator(2f));
reveal.setDuration(800);
reveal.start();
}
});
scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted());

View File

@@ -307,10 +307,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
public Unit showConfirmDeviceCredentialIntent() {
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
Intent intent = null;
if (Build.VERSION.SDK_INT >= 21) {
intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
}
Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
return Unit.INSTANCE;
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.absbackup
/**
* Abstracts away the implementation of pieces of data we want to hand off to various backup services.
* Here we can control precisely which data gets backed up and more importantly, what does not.
*/
interface AndroidBackupItem {
fun getKey(): String
fun getDataForBackup(): ByteArray
fun restoreData(data: ByteArray)
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.absbackup
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.os.ParcelFileDescriptor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.backupables.KbsAuthTokens
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
/**
* Uses the [Android Backup Service](https://developer.android.com/guide/topics/data/keyvaluebackup) and backs up everything in [items]
*/
class SignalBackupAgent : BackupAgent() {
private val items: List<AndroidBackupItem> = listOf(
KbsAuthTokens,
)
override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) {
val contentsHash = cumulativeHashCode()
if (oldState == null) {
performBackup(data)
} else {
val hash = try {
DataInputStream(FileInputStream(oldState.fileDescriptor)).use { it.readInt() }
} catch (e: IOException) {
Log.w(TAG, "No old state, may be first backup request or bug with not writing to newState at end.", e)
}
if (hash != contentsHash) {
performBackup(data)
}
}
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(contentsHash) }
}
private fun performBackup(data: BackupDataOutput) {
items.forEach {
val backupData = it.getDataForBackup()
data.writeEntityHeader(it.getKey(), backupData.size)
data.writeEntityData(backupData, backupData.size)
}
}
override fun onRestore(dataInput: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) {
while (dataInput.readNextHeader()) {
val buffer = ByteArray(dataInput.dataSize)
dataInput.readEntityData(buffer, 0, dataInput.dataSize)
items.find { dataInput.key == it.getKey() }?.restoreData(buffer)
}
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) }
}
private fun cumulativeHashCode(): Int {
return items.fold("") { acc: String, androidBackupItem: AndroidBackupItem -> acc + androidBackupItem.getDataForBackup().decodeToString() }.hashCode()
}
companion object {
private const val TAG = "SignalBackupAgent"
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.absbackup.backupables
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
import org.thoughtcrime.securesms.absbackup.ExternalBackupProtos
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process.
*/
object KbsAuthTokens : AndroidBackupItem {
private const val TAG = "KbsAuthTokens"
override fun getKey(): String {
return TAG
}
override fun getDataForBackup(): ByteArray {
val registrationRecoveryTokenList = SignalStore.kbsValues().kbsAuthTokenList
val proto = ExternalBackupProtos.KbsAuthToken.newBuilder()
.addAllToken(registrationRecoveryTokenList)
.build()
return proto.toByteArray()
}
override fun restoreData(data: ByteArray) {
if (SignalStore.kbsValues().kbsAuthTokenList.isNotEmpty()) {
return
}
try {
val proto = ExternalBackupProtos.KbsAuthToken.parseFrom(data)
SignalStore.kbsValues().putAuthTokenList(proto.tokenList)
} catch (e: InvalidProtocolBufferException) {
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
}
}
}

View File

@@ -13,7 +13,6 @@ import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.RequiresApi
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
private const val WIDTH = "signal.circleavatartransition.width"
@@ -22,7 +21,6 @@ private const val HEIGHT = "signal.circleavatartransition.height"
/**
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
*/
@RequiresApi(21)
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)

View File

@@ -7,11 +7,9 @@ import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
@RequiresApi(21)
class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
companion object {

View File

@@ -10,7 +10,6 @@ import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.animation.addListener
import androidx.fragment.app.FragmentContainerView
@@ -19,7 +18,6 @@ private const val BOUNDS = "signal.wipedowntransition.bottom"
/**
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
*/
@RequiresApi(21)
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)

View File

@@ -8,18 +8,18 @@ import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
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;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.subjects.SingleSubject;
public class AudioRecorder {
private static final String TAG = Log.tag(AudioRecorder.class);
@@ -32,14 +32,20 @@ public class AudioRecorder {
private Recorder recorder;
private Uri captureUri;
private SingleSubject<VoiceNoteDraft> recordingSubject;
public AudioRecorder(@NonNull Context context) {
this.context = context;
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording());
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
stopRecording();
});
}
public void startRecording() {
public @NonNull Single<VoiceNoteDraft> startRecording() {
Log.i(TAG, "startRecording()");
final SingleSubject<VoiceNoteDraft> recordingSingle = SingleSubject.create();
executor.execute(() -> {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try {
@@ -53,27 +59,29 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
}
recorder.start(fds[1]);
this.recordingSubject = recordingSingle;
} catch (IOException e) {
recordingSingle.onError(e);
recorder = null;
Log.w(TAG, e);
}
});
return recordingSingle;
}
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
public void stopRecording() {
Log.i(TAG, "stopRecording()");
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
executor.execute(() -> {
if (recorder == null) {
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
Log.e(TAG, "MediaRecorder was never initialized successfully!");
return;
}
@@ -82,24 +90,15 @@ public class AudioRecorder {
try {
long size = MediaUtil.getMediaSize(context, captureUri);
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
recordingSubject.onSuccess(new VoiceNoteDraft(captureUri, size));
} catch (IOException ioe) {
Log.w(TAG, ioe);
sendToFuture(future, ioe);
recordingSubject.onError(ioe);
}
recorder = null;
captureUri = null;
recordingSubject = null;
recorder = null;
captureUri = null;
});
return future;
}
private <T> void sendToFuture(final SettableFuture<T> future, final Exception exception) {
ThreadUtil.runOnMain(() -> future.setException(exception));
}
private <T> void sendToFuture(final SettableFuture<T> future, final T result) {
ThreadUtil.runOnMain(() -> future.set(result));
}
}

View File

@@ -97,7 +97,7 @@ public class FullBackupExporter extends FullBackupBase {
SignedPreKeyTable.TABLE_NAME,
OneTimePreKeyTable.TABLE_NAME,
SessionTable.TABLE_NAME,
SearchTable.MMS_FTS_TABLE_NAME,
SearchTable.FTS_TABLE_NAME,
EmojiSearchTable.TABLE_NAME,
SenderKeyTable.TABLE_NAME,
SenderKeySharedTable.TABLE_NAME,
@@ -368,7 +368,7 @@ public class FullBackupExporter extends FullBackupBase {
}
boolean isReservedTable = table.startsWith("sqlite_");
boolean isMmsFtsSecretTable = !table.equals(SearchTable.MMS_FTS_TABLE_NAME) && table.startsWith(SearchTable.MMS_FTS_TABLE_NAME);
boolean isMmsFtsSecretTable = !table.equals(SearchTable.FTS_TABLE_NAME) && table.startsWith(SearchTable.FTS_TABLE_NAME);
boolean isEmojiFtsSecretTable = !table.equals(EmojiSearchTable.TABLE_NAME) && table.startsWith(EmojiSearchTable.TABLE_NAME);
return !isReservedTable &&

View File

@@ -127,7 +127,7 @@ public class FullBackupImporter extends FullBackupBase {
}
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchTable.MMS_FTS_TABLE_NAME + "_");
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchTable.FTS_TABLE_NAME + "_");
boolean isForEmojiSecretTable = statement.getStatement().contains(EmojiSearchTable.TABLE_NAME + "_");
boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");

View File

@@ -79,8 +79,7 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
val contacts: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
if (contacts.isNotEmpty()) {
viewModel.setSelectedContact(contacts.first())

View File

@@ -3,6 +3,7 @@ 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;
@@ -14,8 +15,15 @@ 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;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
@@ -35,18 +43,19 @@ 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.conversation.MessageSendType;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
@@ -248,10 +257,6 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (Build.VERSION.SDK_INT < 21) {
return inputConnection;
}
if (mediaListener == null) {
return inputConnection;
}
@@ -280,6 +285,19 @@ public class ComposeText extends EmojiEditText {
return MentionAnnotation.getMentionsFromAnnotations(getText());
}
public boolean hasStyling() {
CharSequence trimmed = getTextTrimmed();
return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
}
public @Nullable BodyRangeList getStyling() {
if (FeatureFlags.textFormatting()) {
return MessageStyler.getStyling(getTextTrimmed());
} else {
return null;
}
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
@@ -290,6 +308,80 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(new MentionDeleter());
mentionValidatorWatcher = new MentionValidatorWatcher();
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuItem copy = menu.findItem(android.R.id.copy);
MenuItem cut = menu.findItem(android.R.id.cut);
MenuItem paste = menu.findItem(android.R.id.paste);
int copyOrder = copy != null ? copy.getOrder() : 0;
int cutOrder = cut != null ? cut.getOrder() : 0;
int pasteOrder = paste != null ? paste.getOrder() : 0;
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Editable text = getText();
if (text == null) {
return false;
}
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) {
return false;
}
int start = getSelectionStart();
int end = getSelectionEnd();
CharSequence charSequence = text.subSequence(start, end);
SpannableString replacement = new SpannableString(charSequence);
CharacterStyle style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = MessageStyler.boldStyle();
} else if (item.getItemId() == R.id.edittext_italic) {
style = MessageStyler.italicStyle();
} else if (item.getItemId() == R.id.edittext_strikethrough) {
style = MessageStyler.strikethroughStyle();
} else if (item.getItemId() == R.id.edittext_monospace) {
style = MessageStyler.monoStyle();
}
if (style != null) {
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
}
clearComposingText();
text.replace(start, end, replacement);
mode.finish();
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {}
});
}
}
private void setHintWithChecks(@Nullable CharSequence newHint) {

View File

@@ -29,6 +29,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -315,6 +317,8 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
} else {
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
@@ -392,7 +396,7 @@ public class ConversationItemFooter extends ConstraintLayout {
previousMessageId = newMessageId;
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) {
deliveryStatusView.setNone();
return;
}

View File

@@ -18,6 +18,7 @@ import com.google.android.material.shape.ShapeAppearanceModel
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.WindowUtil
/**
* Forces rounded corners on BottomSheet
@@ -39,6 +40,11 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
setStyle(STYLE_NORMAL, themeResId)
}
override fun onResume() {
super.onResume()
WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog

View File

@@ -141,9 +141,9 @@ public class InputPanel extends LinearLayout
this.recordTime = new RecordTime(findViewById(R.id.record_time),
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction());
() -> microphoneRecorderView.cancelAction(false));
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction());
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
if (SignalStore.settings().isPreferSystemEmoji()) {
mediaKeyboard.setVisibility(View.GONE);
@@ -268,7 +268,14 @@ public class InputPanel extends LinearLayout
public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions(), quoteView.getQuoteType()));
return Optional.of(new QuoteModel(quoteView.getQuoteId(),
quoteView.getAuthor().getId(),
quoteView.getBody().toString(),
false,
quoteView.getAttachments(),
quoteView.getMentions(),
quoteView.getQuoteType(),
quoteView.getBodyRanges()));
} else {
return Optional.empty();
}
@@ -419,7 +426,7 @@ public class InputPanel extends LinearLayout
listener.onRecorderFinished();
} else {
Toast.makeText(getContext(), R.string.InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send, Toast.LENGTH_LONG).show();
listener.onRecorderCanceled();
listener.onRecorderCanceled(true);
}
}
}
@@ -433,14 +440,15 @@ public class InputPanel extends LinearLayout
if (ViewUtil.isLtr(this) && position <= 0.5 ||
ViewUtil.isRtl(this) && position >= 0.6)
{
this.microphoneRecorderView.cancelAction();
this.microphoneRecorderView.cancelAction(true);
}
}
@Override
public void onRecordCanceled() {
public void onRecordCanceled(boolean byUser) {
Log.d(TAG, "Recording canceled byUser=" + byUser);
onRecordHideEvent();
if (listener != null) listener.onRecorderCanceled();
if (listener != null) listener.onRecorderCanceled(byUser);
}
@Override
@@ -452,7 +460,7 @@ public class InputPanel extends LinearLayout
}
public void onPause() {
this.microphoneRecorderView.cancelAction();
this.microphoneRecorderView.cancelAction(false);
}
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
@@ -527,6 +535,7 @@ public class InputPanel extends LinearLayout
voiceNoteDraftView.setDraft(voiceNoteDraft);
voiceNoteDraftView.setVisibility(VISIBLE);
hideNormalComposeViews();
fadeIn(buttonToggle);
buttonToggle.displayQuick(sendButton);
} else {
voiceNoteDraftView.clearDraft();
@@ -582,7 +591,7 @@ public class InputPanel extends LinearLayout
void onRecorderStarted();
void onRecorderLocked();
void onRecorderFinished();
void onRecorderCanceled();
void onRecorderCanceled(boolean byUser);
void onRecorderPermissionRequired();
void onEmojiToggle();
void onLinkPreviewCanceled();

View File

@@ -16,11 +16,9 @@
*/
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.preference.PreferenceManager;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
@@ -39,6 +37,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* LinearLayout that, when a view container, will report back when it thinks a soft keyboard
@@ -47,6 +46,8 @@ import java.util.Set;
public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
private static final String TAG = Log.tag(KeyboardAwareLinearLayout.class);
private static final long KEYBOARD_DEBOUNCE = 150;
private final Rect rect = new Rect();
private final Set<OnKeyboardHiddenListener> hiddenListeners = new HashSet<>();
private final Set<OnKeyboardShownListener> shownListeners = new HashSet<>();
@@ -65,6 +66,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
private boolean keyboardOpen = false;
private int rotation = 0;
private boolean isBubble = false;
private long openedAt = 0;
public KeyboardAwareLinearLayout(Context context) {
this(context, null);
@@ -107,11 +109,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
}
private void updateKeyboardState() {
updateKeyboardState(Integer.MAX_VALUE);
}
private void updateKeyboardState(int previousHeight) {
if (viewInset == 0 && Build.VERSION.SDK_INT >= 21) viewInset = getViewInset();
if (viewInset == 0) viewInset = getViewInset();
getWindowVisibleDisplayFrame(rect);
@@ -130,11 +128,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
onKeyboardOpen(keyboardHeight);
}
} else if (keyboardOpen) {
if (previousHeight == keyboardHeight) {
onKeyboardClose();
} else {
postDelayed(() -> updateKeyboardState(keyboardHeight), 100);
}
onKeyboardClose();
}
}
@@ -159,7 +153,6 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
}
}
@TargetApi(VERSION_CODES.LOLLIPOP)
private int getViewInset() {
try {
Field attachInfoField = View.class.getDeclaredField("mAttachInfo");
@@ -194,13 +187,21 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
protected void onKeyboardOpen(int keyboardHeight) {
Log.i(TAG, "onKeyboardOpen(" + keyboardHeight + ")");
keyboardOpen = true;
openedAt = System.currentTimeMillis();
notifyShownListeners();
}
protected void onKeyboardClose() {
if (System.currentTimeMillis() - openedAt < KEYBOARD_DEBOUNCE) {
Log.i(TAG, "Delaying onKeyboardClose()");
postDelayed(this::updateKeyboardState, KEYBOARD_DEBOUNCE);
return;
}
Log.i(TAG, "onKeyboardClose()");
keyboardOpen = false;
openedAt = 0;
notifyHiddenListeners();
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.PointF
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewAnimationUtils
@@ -55,10 +54,7 @@ class Material3SearchToolbar @JvmOverloads constructor(
}
fun display(x: Float, y: Float) {
if (Build.VERSION.SDK_INT < 21) {
visibility = VISIBLE
ViewUtil.focusAndShowKeyboard(input)
} else if (!visible) {
if (!visible) {
circularRevealPoint.set(x, y)
val animator = ViewAnimationUtils.createCircularReveal(this, x.toInt(), y.toInt(), 0f, width.toFloat())
@@ -75,17 +71,13 @@ class Material3SearchToolbar @JvmOverloads constructor(
listener?.onSearchClosed()
ViewUtil.hideKeyboard(context, input)
if (Build.VERSION.SDK_INT >= 21) {
val animator = ViewAnimationUtils.createCircularReveal(this, circularRevealPoint.x.toInt(), circularRevealPoint.y.toInt(), width.toFloat(), 0f)
animator.duration = 400
val animator = ViewAnimationUtils.createCircularReveal(this, circularRevealPoint.x.toInt(), circularRevealPoint.y.toInt(), width.toFloat(), 0f)
animator.duration = 400
animator.addListener(onEnd = {
visibility = INVISIBLE
})
animator.start()
} else {
animator.addListener(onEnd = {
visibility = INVISIBLE
}
})
animator.start()
}
}

View File

@@ -58,12 +58,14 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
recordButton.setOnTouchListener(this);
}
public void cancelAction() {
public void cancelAction(boolean byUser) {
if (state != State.NOT_RUNNING) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordCanceled();
if (listener != null) {
listener.onRecordCanceled(byUser);
}
}
}
@@ -138,7 +140,7 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
public interface Listener {
void onRecordPressed();
void onRecordReleased();
void onRecordCanceled();
void onRecordCanceled(boolean byUser);
void onRecordLocked();
void onRecordMoved(float offsetX, float absoluteX);
void onRecordPermissionRequired();

View File

@@ -29,7 +29,9 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
@@ -437,7 +439,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
try {
return StoryTextPostModel.parseFrom(body.toString(), id, author.getId());
return StoryTextPostModel.parseFrom(body.toString(), id, author.getId(), MessageStyler.getStyling(body));
} catch (IOException ioException) {
return null;
}
@@ -471,6 +473,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
return MentionAnnotation.getMentionsFromAnnotations(body);
}
public @Nullable BodyRangeList getBodyRanges() {
return MessageStyler.getStyling(body);
}
private @NonNull ShapeAppearanceModel buildShapeAppearanceForLayoutDirection() {
int fourDp = (int) DimensionUnit.DP.toPixels(4);
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {

View File

@@ -106,15 +106,11 @@ public class SearchToolbar extends LinearLayout {
searchItem.expandActionView();
if (Build.VERSION.SDK_INT >= 21) {
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth());
animator.setDuration(400);
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth());
animator.setDuration(400);
setVisibility(View.VISIBLE);
animator.start();
} else {
setVisibility(View.VISIBLE);
}
setVisibility(View.VISIBLE);
animator.start();
}
}
@@ -129,19 +125,15 @@ public class SearchToolbar extends LinearLayout {
if (listener != null) listener.onSearchClosed();
if (Build.VERSION.SDK_INT >= 21) {
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
animator.setDuration(400);
animator.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.INVISIBLE);
}
});
animator.start();
} else {
setVisibility(View.INVISIBLE);
}
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
animator.setDuration(400);
animator.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.INVISIBLE);
}
});
animator.start();
}
}

View File

@@ -28,6 +28,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
private val listeners: MutableList<SendTypeChangedListener> = CopyOnWriteArrayList()
private var scheduledSendListener: ScheduledSendListener? = null
private var availableSendTypes: List<MessageSendType> = MessageSendType.getAllAvailable(context, false)
private var activeMessageSendType: MessageSendType? = null
@@ -98,6 +99,10 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
}
fun setScheduledSendListener(listener: ScheduledSendListener?) {
this.scheduledSendListener = listener
}
fun resetAvailableTransports(isMediaMessage: Boolean) {
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
activeMessageSendType = null
@@ -150,13 +155,29 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
}
fun showSendTypeMenu(): Boolean {
return if (availableSendTypes.size == 1) {
if (scheduledSendListener == null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
}
false
} else {
showSendTypeContextMenu(false)
true
}
}
override fun onLongClick(v: View): Boolean {
if (!isEnabled) {
return false
}
val scheduleListener = scheduledSendListener
if (availableSendTypes.size == 1) {
return if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
return if (scheduleListener?.canSchedule() == true && selectedSendType.transportType != MessageSendType.TransportType.SMS) {
scheduleListener.onSendScheduled()
true
} else if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
true
} else {
@@ -164,8 +185,14 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
}
val currentlySelected: MessageSendType = selectedSendType
showSendTypeContextMenu(selectedSendType.transportType != MessageSendType.TransportType.SMS)
return true
}
private fun showSendTypeContextMenu(allowScheduling: Boolean) {
val currentlySelected: MessageSendType = selectedSendType
val listener = scheduledSendListener
val items = availableSendTypes
.filterNot { it == currentlySelected }
.map { option ->
@@ -174,17 +201,27 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
title = option.getTitle(context),
action = { setSendType(option) }
)
}
}.toMutableList()
if (allowScheduling && listener?.canSchedule() == true) {
items += ActionItem(
iconRes = R.drawable.ic_calendar_24,
title = context.getString(R.string.conversation_activity__option_schedule_message),
action = { listener.onSendScheduled() }
)
}
SignalContextMenu.Builder((parent as View), popupContainer!!)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.offsetY(ViewUtil.dpToPx(8))
.show(items)
return true
}
interface SendTypeChangedListener {
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
}
interface ScheduledSendListener {
fun onSendScheduled()
fun canSchedule(): Boolean
}
}

View File

@@ -102,9 +102,7 @@ public class TooltipPopup extends PopupWindow {
GlideApp.with(anchor.getContext()).load(iconGlideModel).into(iconView);
}
if (Build.VERSION.SDK_INT >= 21) {
setElevation(10);
}
setElevation(10);
getContentView().setOnClickListener(v -> dismiss());

View File

@@ -31,9 +31,7 @@ public class EmojiVariationSelectorPopup extends PopupWindow {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.emoji_variation_selector_background));
setOutsideTouchable(true);
if (Build.VERSION.SDK_INT >= 21) {
setElevation(20);
}
setElevation(20);
}
public void setVariations(List<String> variations) {

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components.menu
import android.os.Build
import android.view.View
import android.widget.ImageView
import android.widget.TextView
@@ -83,13 +82,11 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
icon.setColorFilter(tintColor)
title.setTextColor(tintColor)
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
@@ -42,10 +41,7 @@ class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : Line
init {
orientation = HORIZONTAL
setBackgroundResource(R.drawable.signal_bottom_action_bar_background)
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
elevation = 20f
}
fun setItems(items: List<ActionItem>) {

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -50,9 +49,7 @@ class SignalContextMenu private constructor(
setOnDismissListener { onDismiss.run() }
}
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
elevation = 20f
contextMenuList.setItems(items)
}

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.components.registration
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
class ActionCountDownButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : MaterialButton(context, attrs, defStyle) {
private var countDownToTime: Long = 0
private var listener: Listener? = null
/**
* Starts a count down to the specified {@param time}.
*/
fun startCountDownTo(time: Long) {
if (time > 0) {
countDownToTime = time
updateCountDown()
}
}
fun setCallEnabled() {
setText(R.string.RegistrationActivity_call)
isEnabled = true
alpha = 1.0f
}
private fun updateCountDown() {
val remainingMillis = countDownToTime - System.currentTimeMillis()
if (remainingMillis > 0) {
isEnabled = false
alpha = 0.5f
val totalRemainingSeconds = TimeUnit.MILLISECONDS.toSeconds(remainingMillis).toInt()
val minutesRemaining = totalRemainingSeconds / 60
val secondsRemaining = totalRemainingSeconds % 60
text = resources.getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining)
listener?.onRemaining(this, totalRemainingSeconds)
postDelayed({ updateCountDown() }, 250)
} else {
setCallEnabled()
}
}
fun setListener(listener: Listener?) {
this.listener = listener
}
interface Listener {
fun onRemaining(view: ActionCountDownButton, secondsRemaining: Int)
}
}

View File

@@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.components.registration;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.button.MaterialButton;
import org.thoughtcrime.securesms.R;
import java.util.concurrent.TimeUnit;
public class CallMeCountDownView extends MaterialButton {
private long countDownToTime;
@Nullable
private Listener listener;
public CallMeCountDownView(Context context) {
super(context);
}
public CallMeCountDownView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CallMeCountDownView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* Starts a count down to the specified {@param time}.
*/
public void startCountDownTo(long time) {
if (time > 0) {
this.countDownToTime = time;
updateCountDown();
}
}
public void setCallEnabled() {
setText(R.string.RegistrationActivity_call);
setEnabled(true);
setAlpha(1.0f);
}
private void updateCountDown() {
final long remainingMillis = countDownToTime - System.currentTimeMillis();
if (remainingMillis > 0) {
setEnabled(false);
setAlpha(0.5f);
int totalRemainingSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(remainingMillis);
int minutesRemaining = totalRemainingSeconds / 60;
int secondsRemaining = totalRemainingSeconds % 60;
setText(getResources().getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining));
if (listener != null) {
listener.onRemaining(this, totalRemainingSeconds);
}
postDelayed(this::updateCountDown, 250);
} else {
setCallEnabled();
}
}
public void setListener(@Nullable Listener listener) {
this.listener = listener;
}
public interface Listener {
void onRemaining(@NonNull CallMeCountDownView view, int secondsRemaining);
}
}

View File

@@ -1,139 +0,0 @@
package org.thoughtcrime.securesms.components.registration;
import android.content.Context;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.OvershootInterpolator;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import java.util.ArrayList;
import java.util.List;
public final class VerificationCodeView extends FrameLayout {
private final List<TextView> codes = new ArrayList<>(6);
private final List<View> containers = new ArrayList<>(6);
private OnCodeEnteredListener listener;
private int index;
public VerificationCodeView(Context context) {
super(context);
initialize(context);
}
public VerificationCodeView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(context);
}
public VerificationCodeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(context);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public VerificationCodeView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(context);
}
private void initialize(@NonNull Context context) {
inflate(context, R.layout.verification_code_view, this);
codes.add(findViewById(R.id.code_zero));
codes.add(findViewById(R.id.code_one));
codes.add(findViewById(R.id.code_two));
codes.add(findViewById(R.id.code_three));
codes.add(findViewById(R.id.code_four));
codes.add(findViewById(R.id.code_five));
containers.add(findViewById(R.id.container_zero));
containers.add(findViewById(R.id.container_one));
containers.add(findViewById(R.id.container_two));
containers.add(findViewById(R.id.container_three));
containers.add(findViewById(R.id.container_four));
containers.add(findViewById(R.id.container_five));
}
@MainThread
public void setOnCompleteListener(OnCodeEnteredListener listener) {
this.listener = listener;
}
@MainThread
public void append(int value) {
if (index >= codes.size()) return;
setInactive(containers);
setActive(containers.get(index));
TextView codeView = codes.get(index++);
Animation translateIn = new TranslateAnimation(0, 0, codeView.getHeight(), 0);
translateIn.setInterpolator(new OvershootInterpolator());
translateIn.setDuration(500);
Animation fadeIn = new AlphaAnimation(0, 1);
fadeIn.setDuration(200);
AnimationSet animationSet = new AnimationSet(false);
animationSet.addAnimation(fadeIn);
animationSet.addAnimation(translateIn);
animationSet.reset();
animationSet.setStartTime(0);
codeView.setText(String.valueOf(value));
codeView.clearAnimation();
codeView.startAnimation(animationSet);
if (index == codes.size() && listener != null) {
listener.onCodeComplete(Stream.of(codes).map(TextView::getText).collect(Collectors.joining()));
}
}
@MainThread
public void delete() {
if (index <= 0) return;
codes.get(--index).setText("");
setInactive(containers);
setActive(containers.get(index));
}
@MainThread
public void clear() {
if (index != 0) {
Stream.of(codes).forEach(code -> code.setText(""));
index = 0;
}
setInactive(containers);
}
private static void setInactive(List<View> views) {
Stream.of(views).forEach(c -> c.setBackgroundResource(R.drawable.labeled_edit_text_background_inactive));
}
private static void setActive(@NonNull View container) {
container.setBackgroundResource(R.drawable.labeled_edit_text_background_active);
}
public interface OnCodeEnteredListener {
void onCodeComplete(@NonNull String code);
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.components.registration
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import com.google.android.material.textfield.TextInputLayout
import org.thoughtcrime.securesms.R
class VerificationCodeView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0) :
FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
private val containers: MutableList<TextInputLayout> = ArrayList(6)
private var listener: OnCodeEnteredListener? = null
private var index = 0
init {
inflate(context, R.layout.verification_code_view, this)
containers.add(findViewById(R.id.container_zero))
containers.add(findViewById(R.id.container_one))
containers.add(findViewById(R.id.container_two))
containers.add(findViewById(R.id.container_three))
containers.add(findViewById(R.id.container_four))
containers.add(findViewById(R.id.container_five))
containers.forEach { it.editText?.showSoftInputOnFocus = false }
}
fun setOnCompleteListener(listener: OnCodeEnteredListener?) {
this.listener = listener
}
fun append(digit: Int) {
if (index >= containers.size) return
containers[index++].editText?.setText(digit.toString())
if (index == containers.size) {
listener?.onCodeComplete(containers.joinToString("") { it.editText?.text.toString() })
return
}
containers[index].editText?.requestFocus()
}
fun delete() {
if (index <= 0) return
containers[--index].editText?.setText("")
}
fun clear() {
if (index != 0) {
containers.forEach { it.editText?.setText("") }
index = 0
}
}
interface OnCodeEnteredListener {
fun onCodeComplete(code: String)
}
}

View File

@@ -1,45 +0,0 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import android.os.Build
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Util
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Shown when a user has API 19.
*/
class Api19Reminder(context: Context) : Reminder(null, context.getString(R.string.API19Reminder_banner_message, getExpireDate())) {
init {
addAction(
Action(
context.getString(R.string.API19Reminder_learn_more),
R.id.reminder_action_api_19_learn_more
)
)
}
override fun isDismissable(): Boolean {
return false
}
override fun getImportance(): Importance {
return Importance.TERMINAL
}
companion object {
@JvmStatic
fun isEligible(): Boolean {
return Build.VERSION.SDK_INT < 21 && !ExpiredBuildReminder.isEligible()
}
fun getExpireDate(): String {
val formatter = SimpleDateFormat("MMMM d", Locale.getDefault())
val expireDate = Date(System.currentTimeMillis() + Util.getTimeUntilBuildExpiry())
return formatter.format(expireDate)
}
}
}

View File

@@ -19,15 +19,8 @@ import java.util.List;
public class ExpiredBuildReminder extends Reminder {
public ExpiredBuildReminder(final Context context) {
super(null, Build.VERSION.SDK_INT < 21
? context.getString(R.string.ExpiredBuildReminder_api_19_message)
: context.getString(R.string.ExpiredBuildReminder_this_version_of_signal_has_expired));
if (Build.VERSION.SDK_INT < 21) {
addAction(new Action(context.getString(R.string.API19Reminder_learn_more), R.id.reminder_action_api_19_learn_more));
} else {
addAction(new Action(context.getString(R.string.ExpiredBuildReminder_update_now), R.id.reminder_action_update_now));
}
super(null, context.getString(R.string.ExpiredBuildReminder_this_version_of_signal_has_expired));
addAction(new Action(context.getString(R.string.ExpiredBuildReminder_update_now), R.id.reminder_action_update_now));
}
@Override

View File

@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.components.settings
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
@@ -230,7 +232,9 @@ class ExternalLinkPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Ex
override fun bind(model: ExternalLinkPreference) {
super.bind(model)
val externalLinkIcon = requireNotNull(ContextCompat.getDrawable(context, R.drawable.ic_open_20))
val externalLinkIcon = requireNotNull(ContextCompat.getDrawable(context, R.drawable.symbol_open_20)).apply {
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.signal_icon_tint_primary), PorterDuff.Mode.SRC_IN)
}
externalLinkIcon.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20))
if (ViewUtil.isLtr(itemView)) {

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.WindowUtil
abstract class DSLSettingsBottomSheetFragment(
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
@@ -39,6 +40,11 @@ abstract class DSLSettingsBottomSheetFragment(
bindAdapter(adapter)
}
override fun onResume() {
super.onResume()
WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!)
}
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {

View File

@@ -55,7 +55,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__account),
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_person_circle_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
}
@@ -63,7 +63,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__linked_devices),
icon = DSLSettingsIcon.from(R.drawable.ic_linked_devices_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
}
@@ -72,8 +72,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
if (state.allowUserToGoToDonationManagementScreen) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.ic_info_solid_24, R.color.signal_accent_primary) else null,
icon = DSLSettingsIcon.from(R.drawable.symbol_heart_24),
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.symbol_info_fill_24, R.color.signal_accent_primary) else null,
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
},
@@ -82,7 +82,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_heart_24),
linkId = R.string.donate_url
)
}
@@ -91,7 +91,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__appearance),
icon = DSLSettingsIcon.from(R.drawable.ic_appearance_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_appearance_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
}
@@ -99,7 +99,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__chats),
icon = DSLSettingsIcon.from(R.drawable.ic_chat_message_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_chat_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
}
@@ -107,7 +107,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__stories),
icon = DSLSettingsIcon.from(R.drawable.ic_stories_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
}
@@ -115,7 +115,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_bell_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_bell_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
}
@@ -123,7 +123,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__privacy),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_lock_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
}
@@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
icon = DSLSettingsIcon.from(R.drawable.ic_archive_24dp),
icon = DSLSettingsIcon.from(R.drawable.symbol_data_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
}
@@ -153,7 +153,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__help),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_help_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
}
@@ -161,7 +161,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends),
icon = DSLSettingsIcon.from(R.drawable.ic_invite_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_invite_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteActivity)
}

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController
import org.thoughtcrime.securesms.registration.util.ChangeNumberInputController
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -54,13 +54,13 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
oldNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_old_number_country_code)
oldNumber = view.findViewById(R.id.change_number_enter_phone_number_old_number_number)
val oldController = RegistrationNumberInputController(
val oldController = ChangeNumberInputController(
requireContext(),
oldNumberCountryCode,
oldNumber,
oldNumberCountrySpinner,
false,
object : RegistrationNumberInputController.Callbacks {
object : ChangeNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, oldNumber.bottom) }, 250)
}
@@ -91,13 +91,13 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
newNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_new_number_country_code)
newNumber = view.findViewById(R.id.change_number_enter_phone_number_new_number_number)
val newController = RegistrationNumberInputController(
val newController = ChangeNumberInputController(
requireContext(),
newNumberCountryCode,
newNumber,
newNumberCountrySpinner,
true,
object : RegistrationNumberInputController.Callbacks {
object : ChangeNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, newNumber.bottom) }, 250)
}

View File

@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsExportSta
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -46,7 +45,7 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
private fun getConfiguration(state: ChatsSettingsState): DSLConfiguration {
return configure {
if (!state.useAsDefaultSmsApp && SignalStore.misc().smsExportPhase.isAtLeastPhase1()) {
if (!state.useAsDefaultSmsApp) {
when (state.smsExportState) {
SmsExportState.FETCHING -> Unit
SmsExportState.HAS_UNEXPORTED_MESSAGES -> {

View File

@@ -8,7 +8,6 @@ import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@@ -19,11 +18,8 @@ import org.thoughtcrime.securesms.components.settings.models.OutlinedLearnMore
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.SmsExportPhase
import org.thoughtcrime.securesms.util.SmsUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val SMS_REQUEST_CODE: Short = 1234
@@ -58,16 +54,14 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
SignalStore.settings().setDefaultSms(true)
} else {
SignalStore.settings().setDefaultSms(false)
if (SignalStore.misc().smsExportPhase.isAtLeastPhase1()) {
findNavController().navigateUp()
}
findNavController().navigateUp()
}
}
private fun getConfiguration(state: SmsSettingsState): DSLConfiguration {
return configure {
if (state.useAsDefaultSmsApp && SignalStore.misc().smsExportPhase.isAtLeastPhase1()) {
if (state.useAsDefaultSmsApp) {
customPref(
OutlinedLearnMore.Model(
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__sms_support_will_be_removed_soon_to_focus_on_encrypted_messaging),
@@ -114,17 +108,13 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
SmsExportState.NOT_AVAILABLE -> Unit
}
if (state.useAsDefaultSmsApp || SignalStore.misc().smsExportPhase == SmsExportPhase.PHASE_0) {
if (state.useAsDefaultSmsApp) {
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__use_as_default_sms_app),
summary = DSLSettingsText.from(if (state.useAsDefaultSmsApp) R.string.arrays__enabled else R.string.arrays__disabled),
summary = DSLSettingsText.from(R.string.arrays__enabled),
onClick = {
if (state.useAsDefaultSmsApp) {
startDefaultAppSelectionIntent()
} else {
startActivityForResult(SmsUtil.getSmsRoleIntent(requireContext()), SMS_REQUEST_CODE.toInt())
}
startDefaultAppSelectionIntent()
}
)
}
@@ -146,15 +136,6 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
viewModel.setWifiCallingCompatibilityEnabled(!state.wifiCallingCompatibilityEnabled)
}
)
if (Build.VERSION.SDK_INT < 21) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__advanced_mms_access_point_names),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_smsSettingsFragment_to_mmsPreferencesFragment)
}
)
}
}
}

View File

@@ -5,17 +5,12 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.FeatureFlags
class SmsSettingsRepository(
private val smsDatabase: MessageTable = SignalDatabase.messages,
private val mmsDatabase: MessageTable = SignalDatabase.messages
) {
fun getSmsExportState(): Single<SmsExportState> {
if (!FeatureFlags.smsExporter()) {
return Single.just(SmsExportState.NOT_AVAILABLE)
}
return Single.fromCallable {
checkInsecureMessageCount() ?: checkUnexportedInsecureMessageCount()
}.subscribeOn(Schedulers.io())

View File

@@ -178,7 +178,7 @@ class EditNotificationProfileFragment : DSLSettingsFragment(layoutId = R.layout.
emojiView?.setImageDrawable(drawable)
viewModel.onEmojiSelected(emoji)
} else {
emojiView?.setImageResource(R.drawable.ic_add_emoji)
emojiView?.setImageResource(R.drawable.symbol_emoji_plus_24)
viewModel.onEmojiSelected("")
}
}

View File

@@ -62,6 +62,16 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
* Payment failed by the credit card processor, with a specific reason told to us by Stripe.
*/
class StripeDeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode, val method: PaymentSourceType.Stripe) : PaymentSetupError(source, cause)
/**
* Payment setup failed in some way, which we are told about by PayPal.
*/
class PayPalCodedError(source: DonationErrorSource, cause: Throwable, val errorCode: Int) : PaymentSetupError(source, cause)
/**
* Payment failed by the credit card processor, with a specific reason told to us by PayPal.
*/
class PayPalDeclinedError(source: DonationErrorSource, cause: Throwable, val code: PayPalDeclineCode.KnownCode) : PaymentSetupError(source, cause)
}
/**

View File

@@ -0,0 +1,128 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
/**
* From: https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes
*/
data class PayPalDeclineCode(
val code: Int
) {
val knownCode: KnownCode? = KnownCode.fromCode(code)
enum class KnownCode(val code: Int) {
DO_NOT_HONOR(2000),
INSUFFICIENT_FUNDS(2001),
LIMIT_EXCEEDED(2002),
CARDHOLDER_ACTIVITY_LIMIT_EXCEEDED(2003),
EXPIRED_CARD(2004),
INVALID_CREDIT_CARD(2005),
INVALID_EXPIRATION_DATE(2006),
NO_ACCOUNT(2007),
CARD_ACCOUNT_LENGTH_ERROR(2008),
NO_SUCH_ISSUER(2009),
CARD_ISSUER_DECLINED_CVV(2010),
VOICE_AUTHORIZATION_REQUIRED(2011),
PROCESSOR_DECLINED_POSSIBLE_LOST_CARD(2012),
PROCESSOR_DECLINED_POSSIBLE_STOLEN_CARD(2013),
PROCESSOR_DECLINED_FRAUD_SUSPECTED(2014),
TRANSACTION_NOT_ALLOWED(2015),
DUPLICATE_TRANSACTION(2016),
CARDHOLDER_STOPPED_BILLING(2017),
CARDHOLDER_STOPPED_ALL_BILLING(2018),
INVALID_TRANSACTION(2019),
VIOLATION(2020),
SECURITY_VIOLATION(2021),
DECLINED_UPDATED_CARDHOLDER_AVAILABLE(2022),
PROCESSOR_DOES_NOT_SUPPORT_THIS_FEATURE(2023),
CARD_TYPE_NOT_ENABLED(2024),
SET_UP_ERROR_MERCHANT(2025),
INVALID_MERCHANT_ID(2026),
SET_UP_ERROR_AMOUNT(2027),
SET_UP_ERROR_HIERARCHY(2028),
SET_UP_ERROR_CARD(2029),
SET_UP_ERROR_TERMINAL(2030),
ENCRYPTION_ERROR(2031),
SURCHARGE_NOT_PERMITTED(2032),
INCONSISTENT_DATA(2033),
NO_ACTION_TAKEN(2034),
PARTIAL_APPROVAL_FOR_AMOUNT_IN_GROUP_3_VERSION(2035),
AUTHORIZATION_COULD_NOT_BE_FOUND(2036),
ALREADY_REVERSED(2037),
PROCESSOR_DECLINED(2038),
INVALID_AUTHORIZATION_CODE(2039),
INVALID_STORE(2040),
DECLINED_CALL_FOR_APPROVAL(2041),
INVALID_CLIENT_ID(2042),
ERROR_DO_NOT_RETRY_CALL_ISSUER(2043),
DECLINED_CALL_ISSUER(2044),
INVALID_MERCHANT_NUMBER(2045),
DECLINED(2046),
CALL_ISSUER_PICK_UP_CARD(2047),
INVALID_AMOUNT(2048),
INVALID_SKU_NUMBER(2049),
INVALID_CREDIT_PLAN(2050),
CREDIT_CARD_NUMBER_DOES_NOT_MATCH_METHOD_OF_PAYMENT(2051),
INVALID_LEVEL_3_PURCHASE(2052),
CARD_REPORTED_AS_LOST_OR_STOLEN(2053),
REVERSAL_AMOUNT_DOES_NOT_MATCH_AUTHORIZATION_AMOUNT(2054),
INVALID_TRANSACTION_DIVISION_NUMBER(2055),
TRANSACTION_AMOUNT_EXCEEDS_THE_TRANSACTION_DIVISION_LIMIT(2056),
ISSUER_OR_CARDHOLDER_HAS_PUT_A_RESTRICTION_ON_THE_CARD(2057),
MERCHANT_NOT_MASTERCARD_SECURECODE_ENABLED(2058),
ADDRESS_VERIFICATION_FAILED(2059),
ADDRESS_VERIFICATION_AND_CARD_SECURITY_CODE_FAILED(2060),
INVALID_TRANSACTION_DATA(2061),
INVALID_TAX_AMOUNT(2062),
PAYPAL_BUSINESS_ACCOUNT_PREFERENCE_RESULTED_IN_THE_TRANSACTION_FAILING(2063),
INVALID_CURRENCY_CODE(2064),
REFUND_TIME_LIMIT_EXCEEDED(2065),
PAYPAL_BUSINESS_ACCOUNT_RESTRICTED(2066),
AUTHORIZATION_EXPIRED(2067),
PAYPAL_BUSINESS_ACCOUNT_LOCKED_OR_CLOSED(2068),
PAYPAL_BLOCKING_DUPLICATE_ORDER_IDS(2069),
PAYPAL_BUYER_REVOKED_PRE_APPROVED_PAYMENT_AUTHORIZATION(2070),
PAYPAL_PAYEE_ACCOUNT_INVALID_OR_DOES_NOT_HAVE_A_VERIFIED_EMAIL(2071),
PAYPAL_PAYEE_EMAIL_INCORRECTLY_FORMATTED(2072),
PAYPAL_VALIDATION_ERROR(2073),
FUNDING_INSTRUMENT_IN_THE_PAYPAL_ACCOUNT_WAS_DECLINED_BY_THE_PROCESSR_OR_BANK_OR_IT_CANT_BE_USED_FOR_THIS_PAYMENT(2074),
PAYER_ACCOUNT_IS_LOCKED_OR_CLOSED(2075),
PAYER_CANNOT_PAY_FOR_THIS_TRANSACTION_WITH_PAYPAL(2076),
TRANSACTION_REFUSED_DUE_TO_PAYPAL_RISK_MODEL(2077),
INVALID_SECURE_PAYMENT_DATA(2078),
PAYPAL_MERCHANT_ACCOUNT_CONFIGURATION_ERROR(2079),
INVALID_USER_CREDENTIALS(2080),
PAYPAL_PENDING_PAYMENTS_ARE_NOT_SUPPORTED(2081),
PAYPAL_DOMESTIC_TRANSACTION_REQUIRED(2082),
PAYPAL_PHONE_NUMBER_REQUIRED(2083),
PAYPAL_TAX_INFO_REQUIRED(2084),
PAYPAL_PAYEE_BLOCKED_TRANSACTION(2085),
PAYPAL_TRANSACTION_LIMIT_EXCEEDED(2086),
PAYPAL_REFERENCE_TRANSACTIONS_ARE_NOT_ENABLED_FOR_YOUR_ACCOUNT(2087),
CURRENCY_NOT_ENABLED_FOR_YOUR_PAYPAL_SELLER_ACCOUNT(2088),
PAYPAL_PAYEE_EMAIL_PERMISSION_DENIED_FOR_THIS_REQUEST(2089),
PAYPAL_OR_VENMO_ACCOUNT_NOT_CONFIGURED_TO_REFUND_MORE_THAN_SETTLED_AMOUNT(2090),
CURRENCY_OF_THIS_TRANSACTION_MUST_MATCH_CURRENCY_OF_YOUR_PAYPAL_ACCOUNT(2091),
NO_DATA_FOUND_TRY_ANOTHER_VERIFICATION_METHOD(2092),
PAYPAL_PAYMENT_METHOD_IS_INVALID(2093),
PAYPAL_PAYMENT_HAS_ALREADY_BEEN_COMPLETED(2094),
PAYPAL_REFUND_IS_NOT_ALLOWED_AFTER_PARTIAL_REFUND(2095),
PAYPAL_BUYER_ACCOUNT_CANT_BE_THE_SAME_AS_THE_SELLER_ACCOUNT(2096),
PAYPAL_AUTHORIZATION_AMOUNT_LIMIT_EXCEEDED(2097),
PAYPAL_AUTHORIZATION_COUNT_LIMIT_EXCEEDED(2098),
CARDHOLDER_AUTHORIZATION_REQUIRED(2099),
PAYPAL_CHANNEL_INITIATED_BILLING_NOT_ENABLED_FOR_YOUR_ACCOUNT(2100),
ADDITIONAL_AUTHORIZATION_REQUIRED(2101),
INCORRECT_PIN(2102),
PIN_TRY_EXCEEDED(2103),
OFFLINE_ISSUER_DECLINED(2104),
CANNOT_AUTHORIZE_AT_THIS_TIME_LIFE_CYCLE(2105),
CANNOT_AUTHORIZE_AT_THIS_TIME_POLICY(2106),
CARD_NOT_ACTIVATED(2107),
CLOSED_CARD(2108),
PROCESSOR_NETWORK_UNAVAILABLE_TRY_AGAIN(3000);
companion object {
fun fromCode(code: Int): KnownCode? = values().firstOrNull { it.code == code }
}
}
}

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -218,7 +217,7 @@ class ManageDonationsFragment :
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
icon = DSLSettingsIcon.from(R.drawable.symbol_person_24),
isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
@@ -245,10 +244,10 @@ class ManageDonationsFragment :
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
if (FeatureFlags.giftBadgeSendSupport() && Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) {
if (Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_for_a_friend),
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_gift_24),
onClick = {
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
}
@@ -259,7 +258,7 @@ class ManageDonationsFragment :
private fun DSLConfiguration.presentBadges() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_badge_multi_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
}
@@ -269,7 +268,7 @@ class ManageDonationsFragment :
private fun DSLConfiguration.presentReceipts() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_receipt_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
}
@@ -285,7 +284,7 @@ class ManageDonationsFragment :
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_help_24),
linkId = R.string.donate_url
)
}

View File

@@ -111,7 +111,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
lottie.visible = true
lottie.playAnimation()
lottie.addAnimatorListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) {
override fun onAnimationEnd(animation: Animator) {
lottie.removeAnimatorListener(this)
lottie.setMinAndMaxFrame(30, 91)
lottie.repeatMode = LottieDrawable.RESTART

View File

@@ -277,7 +277,7 @@ sealed class ConversationSettingsViewModel(
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = true,
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.storyValues().isFeatureDisabled
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
specificSettingsState = state.requireGroupSettingsState().copy(

View File

@@ -5,7 +5,6 @@ import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.support.v4.media.session.MediaControllerCompat
@@ -29,11 +28,7 @@ class VoiceNoteProximityWakeLockManager(
private val mediaController: MediaControllerCompat
) : DefaultLifecycleObserver {
private val wakeLock: PowerManager.WakeLock? = if (Build.VERSION.SDK_INT >= 21) {
ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
} else {
null
}
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)

View File

@@ -190,11 +190,8 @@ public final class WebRtcAnswerDeclineButton extends LinearLayout implements Acc
swipeUpText.setAlpha(1);
swipeDownText.setAlpha(1);
answer.setRotation(0);
if (Build.VERSION.SDK_INT >= 21) {
answer.getDrawable().setTint(getResources().getColor(R.color.green_600));
answer.getBackground().setTint(Color.WHITE);
}
answer.getDrawable().setTint(getResources().getColor(R.color.green_600));
answer.getBackground().setTint(Color.WHITE);
animating = true;
animateElements(0);

View File

@@ -131,6 +131,14 @@ public class ContactRepository {
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
@WorkerThread
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getGroupMemberContacts()
: recipientTable.queryGroupMemberContacts(query);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
private @NonNull Cursor handleNoteToSelfQuery(@NonNull String query, boolean includeSelf, Cursor cursor) {
if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
Recipient self = Recipient.self();

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
interface ArbitraryRepository {
/**
* Get the count of arbitrary rows to include for the given query from the given section.
*/
fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int
/**
* Get the data for the given arbitrary rows within the start and end index.
*/
fun getData(
section: ContactSearchConfiguration.Section.Arbitrary,
query: String?,
startIndex: Int,
endIndex: Int,
totalSearchSize: Int
): List<ContactSearchData.Arbitrary>
/**
* Map an arbitrary object to a mapping model
*/
fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*>
}

View File

@@ -18,79 +18,95 @@ import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.Recipient.FallbackPhotoProvider
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.visible
private typealias StoryClickListener = (View, ContactSearchData.Story, Boolean) -> Unit
private typealias RecipientClickListener = (View, ContactSearchData.KnownRecipient, Boolean) -> Unit
/**
* Mapping Models and View Holders for ContactSearchData
* Default contact search adapter, using the models defined in `ContactSearchItems`
*/
object ContactSearchItems {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: StoryClickListener,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
)
@Suppress("LeakingThis")
open class ContactSearchAdapter(
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
) : PagingMappingAdapter<ContactSearchKey>() {
init {
registerStoryItems(this, displayCheckBox, storyListener, storyContextMenuCallbacks)
registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, recipientListener)
registerHeaders(this)
registerExpands(this, expandListener)
}
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
companion object {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
)
}
fun register(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: RecipientClickListener,
storyListener: StoryClickListener,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
) {
registerStoryItems(mappingAdapter, displayCheckBox, storyListener, storyContextMenuCallbacks)
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, displaySmsTag, recipientListener) }, R.layout.contact_search_item)
)
registerHeaders(mappingAdapter)
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, displaySmsTag, recipientListener) }, R.layout.contact_search_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.storyValues().userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.storyValues().userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
}
}
}
)
)
}
}
/**
* Story Model
*/
private class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
@@ -117,7 +133,7 @@ object ContactSearchItems {
private class StoryViewHolder(
itemView: View,
displayCheckBox: Boolean,
onClick: StoryClickListener,
onClick: (View, ContactSearchData.Story, Boolean) -> Unit,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?
) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, DisplaySmsTag.NEVER, onClick) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
@@ -216,7 +232,7 @@ object ContactSearchItems {
}
}
private class MyStoryFallbackPhotoProvider(private val name: String, private val targetSize: Int) : FallbackPhotoProvider() {
private class MyStoryFallbackPhotoProvider(private val name: String, private val targetSize: Int) : Recipient.FallbackPhotoProvider() {
override fun getPhotoForLocalNumber(): FallbackContactPhoto {
return GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40, targetSize)
}
@@ -226,7 +242,7 @@ object ContactSearchItems {
/**
* Recipient model
*/
private class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean, val shortSummary: Boolean) : MappingModel<RecipientModel> {
class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean, val shortSummary: Boolean) : MappingModel<RecipientModel> {
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
@@ -249,7 +265,7 @@ object ContactSearchItems {
itemView: View,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
onClick: RecipientClickListener
onClick: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
@@ -259,10 +275,13 @@ object ContactSearchItems {
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.shortSummary && recipient.isGroup) {
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else {
super.bindNumberField(model)
}
@@ -278,7 +297,8 @@ object ContactSearchItems {
/**
* Base Recipient View Holder
*/
private abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
private val displayCheckBox: Boolean,
private val displaySmsTag: DisplaySmsTag,
@@ -342,7 +362,6 @@ object ContactSearchItems {
DisplaySmsTag.IF_NOT_REGISTERED -> isNotRegistered(model)
DisplaySmsTag.NEVER -> false
}
smsTag.visible = isSmsContact(model)
}
protected open fun bindLongPress(model: T) = Unit
@@ -363,7 +382,7 @@ object ContactSearchItems {
/**
* Mapping Model for section headers
*/
private class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
@@ -375,6 +394,32 @@ object ContactSearchItems {
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* View Holder for section headers
*/
@@ -390,6 +435,10 @@ object ContactSearchItems {
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.ARBITRARY -> error("This section does not support HEADER")
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
}
)
@@ -407,7 +456,7 @@ object ContactSearchItems {
/**
* Mapping Model for expandable content rows.
*/
private class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction
*/
class ContactSearchConfiguration private constructor(
val query: String?,
val hasEmptyState: Boolean,
val sections: List<Section>
) {
sealed class Section(val sectionKey: SectionKey) {
@@ -69,16 +70,75 @@ class ContactSearchConfiguration private constructor(
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUPS)
data class Arbitrary(
val types: Set<String>
) : Section(SectionKey.ARBITRARY) {
override val includeHeader: Boolean = false
override val expandConfig: ExpandConfig? = null
}
data class GroupMembers(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUP_MEMBERS)
data class Chats(
val isUnreadOnly: Boolean = false,
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.CHATS)
data class Messages(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.MESSAGES)
}
/**
* Describes a given section. Useful for labeling sections and managing expansion state.
*/
enum class SectionKey {
/**
* Lists My Stories, distribution lists, as well as group stories.
*/
STORIES,
/**
* Recent chats.
*/
RECENTS,
/**
* 1:1 Contacts with whom I've started a chat.
*/
INDIVIDUALS,
GROUPS
/**
* Active groups the user is a member of
*/
GROUPS,
/**
* Arbitrary row (think new group button, username row, etc)
*/
ARBITRARY,
/**
* Contacts that are members of groups user is in that they've not explicitly
* started a conversation with.
*/
GROUP_MEMBERS,
/**
* 1:1 and Group chats
*/
CHATS,
/**
* Messages from 1:1 and Group chats
*/
MESSAGES
}
/**
@@ -109,6 +169,7 @@ class ContactSearchConfiguration private constructor(
* }
* ```
*/
@JvmStatic
fun build(builderFunction: Builder.() -> Unit): ContactSearchConfiguration {
return ConfigurationBuilder().let {
it.builderFunction()
@@ -124,13 +185,14 @@ class ContactSearchConfiguration private constructor(
private val sections: MutableList<Section> = mutableListOf()
override var query: String? = null
override var hasEmptyState: Boolean = false
override fun addSection(section: Section) {
sections.add(section)
}
fun build(): ContactSearchConfiguration {
return ContactSearchConfiguration(query, sections)
return ContactSearchConfiguration(query, hasEmptyState, sections)
}
}
@@ -139,6 +201,12 @@ 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()))
}
fun addSection(section: Section)
}
}

View File

@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.contacts.paged
import android.os.Bundle
import androidx.annotation.VisibleForTesting
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.MessageResult
/**
* Represents the data backed by a ContactSearchKey
@@ -18,16 +21,34 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
val recipient: Recipient,
val count: Int,
val privacyMode: DistributionListPrivacyMode
) : ContactSearchData(ContactSearchKey.RecipientSearchKey.Story(recipient.id))
) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, true))
/**
* A row displaying a known recipient.
*/
data class KnownRecipient(
val sectionKey: ContactSearchConfiguration.SectionKey,
val recipient: Recipient,
val shortSummary: Boolean = false,
val headerLetter: String? = null
) : ContactSearchData(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.id))
val headerLetter: String? = null,
val groupsInCommon: GroupsInCommon = GroupsInCommon(0, listOf())
) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false))
/**
* A row displaying a message
*/
data class Message(
val query: String,
val messageResult: MessageResult
) : ContactSearchData(ContactSearchKey.Message(messageResult.messageId))
/**
* A row displaying a thread
*/
data class Thread(
val query: String,
val threadRecord: ThreadRecord
) : ContactSearchData(ContactSearchKey.Thread(threadRecord.threadId))
/**
* A row containing a title for a given section
@@ -42,6 +63,16 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
*/
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
/**
* A row representing arbitrary data tied to a specific section.
*/
class Arbitrary(val type: String, val data: Bundle? = null) : ContactSearchData(ContactSearchKey.Arbitrary(type))
/**
* Empty state, only included if no other rows exist.
*/
data class Empty(val query: String?) : ContactSearchData(ContactSearchKey.Empty)
/**
* A row which contains an integer, for testing.
*/

View File

@@ -12,44 +12,18 @@ sealed class ContactSearchKey {
/**
* Generates a ShareContact object used to display which contacts have been selected. This should *not*
* be used for the final sharing process, as it is not always truthful about, for example, KnownRecipient of
* be used for the final sharing process, as it is not always truthful about, for example,
* a group vs. a group's Story.
*/
open fun requireShareContact(): ShareContact = error("This key cannot be converted into a ShareContact")
open fun requireParcelable(): ParcelableRecipientSearchKey = error("This key cannot be parcelized")
open fun requireRecipientSearchKey(): RecipientSearchKey = error("This key cannot be parcelized")
sealed class RecipientSearchKey : ContactSearchKey() {
@Parcelize
data class RecipientSearchKey(val recipientId: RecipientId, val isStory: Boolean) : ContactSearchKey(), Parcelable {
override fun requireRecipientSearchKey(): RecipientSearchKey = this
abstract val recipientId: RecipientId
abstract val isStory: Boolean
data class Story(override val recipientId: RecipientId) : RecipientSearchKey() {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): ParcelableRecipientSearchKey {
return ParcelableRecipientSearchKey(ParcelableType.STORY, recipientId)
}
override val isStory: Boolean = true
}
/**
* Key to a recipient which already exists in our database
*/
data class KnownRecipient(override val recipientId: RecipientId) : RecipientSearchKey() {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): ParcelableRecipientSearchKey {
return ParcelableRecipientSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
}
override val isStory: Boolean = false
}
override fun requireShareContact(): ShareContact = ShareContact(recipientId)
}
/**
@@ -62,18 +36,22 @@ sealed class ContactSearchKey {
*/
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
@Parcelize
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
fun asRecipientSearchKey(): RecipientSearchKey {
return when (type) {
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)
}
}
}
/**
* Arbitrary takes a string type and will map to exactly one ArbitraryData object.
*
* This is used to allow arbitrary extra data to be added to the contact search system.
*/
data class Arbitrary(val type: String) : ContactSearchKey()
enum class ParcelableType {
STORY,
KNOWN_RECIPIENT
}
/**
* Search key for a ThreadRecord
*/
data class Thread(val threadId: Long) : ContactSearchKey()
/**
* Search key for a MessageRecord
*/
data class Message(val messageId: Long) : ContactSearchKey()
object Empty : ContactSearchKey()
}

View File

@@ -5,48 +5,58 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.concurrent.TimeUnit
class ContactSearchMediator(
private val fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
displayCheckBox: Boolean,
displaySmsTag: ContactSearchItems.DisplaySmsTag,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
private val contactSelectionPreFilter: (View?, Set<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s },
performSafetyNumberChecks: Boolean = true
performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null,
) {
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository(), performSafetyNumberChecks)).get(ContactSearchViewModel::class.java)
private val queryDebouncer = Debouncer(300, TimeUnit.MILLISECONDS)
private val viewModel: ContactSearchViewModel = ViewModelProvider(
fragment,
ContactSearchViewModel.Factory(
selectionLimits = selectionLimits,
repository = ContactSearchRepository(),
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = SearchRepository(fragment.requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(fragment.requireContext())
)
)[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create(
displayCheckBox = displayCheckBox,
displaySmsTag = displaySmsTag,
recipientListener = this::toggleSelection,
storyListener = this::toggleStorySelection,
storyContextMenuCallbacks = StoryContextMenuCallbacks()
) { viewModel.expandSection(it.sectionKey) }
init {
val adapter = PagingMappingAdapter<ContactSearchKey>()
recyclerView.adapter = adapter
ContactSearchItems.register(
mappingAdapter = adapter,
displayCheckBox = displayCheckBox,
displaySmsTag = displaySmsTag,
recipientListener = this::toggleSelection,
storyListener = this::toggleStorySelection,
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
expandListener = { viewModel.expandSection(it.sectionKey) }
)
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
@@ -54,7 +64,7 @@ class ContactSearchMediator(
)
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchItems.toMappingModelList(data, selection))
adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository))
}
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
@@ -67,7 +77,13 @@ class ContactSearchMediator(
}
fun onFilterChanged(filter: String?) {
viewModel.setQuery(filter)
queryDebouncer.publish {
viewModel.setQuery(filter)
}
}
fun onConversationFilterRequestChanged(conversationFilterRequest: ConversationFilterRequest) {
viewModel.setConversationFilterRequest(conversationFilterRequest)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
@@ -90,7 +106,7 @@ class ContactSearchMediator(
return viewModel.errorEventsStream.observeOn(AndroidSchedulers.mainThread())
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey.Story>) {
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
viewModel.addToVisibleGroupStories(groupStories)
}
@@ -114,7 +130,7 @@ class ContactSearchMediator(
}
}
private inner class StoryContextMenuCallbacks : ContactSearchItems.StoryContextMenuCallbacks {
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog()
@@ -143,4 +159,32 @@ class ContactSearchMediator(
.show()
}
}
/**
* Wraps the construction of a PagingMappingAdapter<ContactSearchKey> so that it can
* be swapped for another implementation, allow listeners to be wrapped, etc.
*/
fun interface AdapterFactory {
fun create(
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
): PagingMappingAdapter<ContactSearchKey>
}
private object DefaultAdapterFactory : AdapterFactory {
override fun create(
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(displayCheckBox, displaySmsTag, recipientListener, storyListener, storyContextMenuCallbacks, expandListener)
}
}
}

View File

@@ -8,10 +8,14 @@ import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterato
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.ThreadSearchResult
import java.util.concurrent.TimeUnit
/**
@@ -19,7 +23,9 @@ import java.util.concurrent.TimeUnit
*/
class ContactSearchPagedDataSource(
private val contactConfiguration: ContactSearchConfiguration,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication())
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
private val arbitraryRepository: ArbitraryRepository? = null,
private val searchRepository: SearchRepository? = null
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
companion object {
@@ -30,13 +36,26 @@ class ContactSearchPagedDataSource(
private val activeStoryCount = latestStorySends.size
private var searchCache = SearchCache()
private var searchSize = -1
override fun size(): Int {
return contactConfiguration.sections.sumOf {
searchSize = contactConfiguration.sections.sumOf {
getSectionSize(it, contactConfiguration.query)
}
return if (searchSize == 0 && contactConfiguration.hasEmptyState) {
1
} else {
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 sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
val startIndex: Index = findIndex(sizeMap, start)
val endIndex: Index = findIndex(sizeMap, start + length)
@@ -89,6 +108,10 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).getCollectionSize(section, query, this::canSendToGroup)
is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.")
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSize(section, query, null)
}
}
@@ -119,6 +142,10 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Recents -> getRecentsContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Stories -> getStoriesContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getData(section, query, startIndex, endIndex, searchSize) ?: error("Invalid arbitrary section.")
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Chats -> getThreadContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Messages -> getMessageContactData(section, query, startIndex, endIndex)
}
}
@@ -149,6 +176,10 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section))
}
private fun getGroupMembersSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(query))
}
private fun <R> readContactData(
records: ContactSearchIterator<R>,
recordsPredicate: ((R) -> Boolean)?,
@@ -194,7 +225,7 @@ class ContactSearchPagedDataSource(
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it))
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it))
}
)
}
@@ -216,7 +247,7 @@ class ContactSearchPagedDataSource(
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it)
ContactSearchData.KnownRecipient(recipient, headerLetter = headerMap[recipient.id])
ContactSearchData.KnownRecipient(section.sectionKey, recipient, headerLetter = headerMap[recipient.id])
}
)
}
@@ -234,7 +265,7 @@ class ContactSearchPagedDataSource(
if (section.returnAsGroupStories) {
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), 0, DistributionListPrivacyMode.ALL)
} else {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary)
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary)
}
}
)
@@ -251,6 +282,80 @@ class ContactSearchPagedDataSource(
}
}
private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getGroupMembersSearchIterator(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it)
val groupsInCommon = contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient)
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon)
}
)
}
}
private fun getMessageData(query: String?): ContactSearchIterator<MessageResult> {
check(searchRepository != null)
if (searchCache.messageSearchResult == null && query != null) {
searchCache = searchCache.copy(messageSearchResult = searchRepository.queryMessagesSync(query))
}
return if (query != null) {
ListSearchIterator(searchCache.messageSearchResult!!.results)
} else {
ListSearchIterator(emptyList())
}
}
private fun getMessageContactData(section: ContactSearchConfiguration.Section.Messages, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getMessageData(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.Message(query ?: "", it)
}
)
}
}
private fun getThreadData(query: String?, unreadOnly: Boolean): ContactSearchIterator<ThreadRecord> {
check(searchRepository != null)
if (searchCache.threadSearchResult == null && query != null) {
searchCache = searchCache.copy(threadSearchResult = searchRepository.queryThreadsSync(query, unreadOnly))
}
return if (query != null) {
ListSearchIterator(searchCache.threadSearchResult!!.results)
} else {
ListSearchIterator(emptyList())
}
}
private fun getThreadContactData(section: ContactSearchConfiguration.Section.Chats, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getThreadData(query, section.isUnreadOnly).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.Thread(query ?: "", it)
}
)
}
}
private fun <R> createResultsCollection(
section: ContactSearchConfiguration.Section,
records: ContactSearchIterator<R>,
@@ -264,6 +369,14 @@ class ContactSearchPagedDataSource(
}
}
/**
* Caches search results of particularly intensive queries.
*/
private data class SearchCache(
val messageSearchResult: MessageSearchResult? = null,
val threadSearchResult: ThreadSearchResult? = null
)
/**
* StoryComparator
*/
@@ -282,4 +395,21 @@ class ContactSearchPagedDataSource(
}
}
}
private class ListSearchIterator<T>(val list: List<T>) : ContactSearchIterator<T> {
private var position = -1
override fun moveToPosition(n: Int) {
position = n
}
override fun getCount(): Int = list.size
override fun hasNext(): Boolean = position < list.lastIndex
override fun next(): T = list[++position]
override fun close() = Unit
}
}

View File

@@ -22,10 +22,11 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* having to deal with database access.
*/
open class ContactSearchPagedDataSourceRepository(
private val context: Context
context: Context
) {
private val contactRepository = ContactRepository(context, context.getString(R.string.note_to_self))
private val context = context.applicationContext
open fun getLatestStorySends(activeStoryCutoffDuration: Long): List<StorySend> {
return SignalStore.storyValues()
@@ -48,6 +49,10 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
}
open fun queryGroupMemberContacts(query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "")
}
open fun getGroupSearchIterator(
section: ContactSearchConfiguration.Section.Groups,
query: String?
@@ -95,6 +100,16 @@ open class ContactSearchPagedDataSourceRepository(
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getGroupsInCommon(recipient: Recipient): GroupsInCommon {
val groupsInCommon = SignalDatabase.groups.getPushGroupsContainingMember(recipient.id)
val groupRecipientIds = groupsInCommon.take(2).map { it.recipientId }
val names = Recipient.resolvedList(groupRecipientIds)
.map { it.getDisplayName(context) }
.sorted()
return GroupsInCommon(groupsInCommon.size, names)
}
open fun getRecipientFromGroupRecord(groupRecord: GroupRecord): Recipient {
return Recipient.resolved(groupRecord.recipientId)
}

View File

@@ -19,10 +19,8 @@ class ContactSearchRepository {
return Single.fromCallable {
contactSearchKeys.map {
val isSelectable = when (it) {
is ContactSearchKey.Expand -> false
is ContactSearchKey.Header -> false
is ContactSearchKey.RecipientSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId)
is ContactSearchKey.RecipientSearchKey.Story -> canSelectRecipient(it.recipientId)
is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId)
else -> false
}
ContactSearchSelectionResult(it, isSelectable)
}.toSet()

View File

@@ -1,10 +1,13 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
/**
* Simple search state for contacts.
*/
data class ContactSearchState(
val query: String? = null,
val conversationFilterRequest: ConversationFilterRequest? = null,
val expandedSections: Set<ContactSearchConfiguration.SectionKey> = emptySet(),
val groupStories: Set<ContactSearchData.Story> = emptySet()
)

View File

@@ -13,9 +13,11 @@ import org.signal.paging.LivePagedData
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.util.Preconditions
@@ -27,6 +29,9 @@ class ContactSearchViewModel(
private val contactSearchRepository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean,
private val safetyNumberRepository: SafetyNumberRepository = SafetyNumberRepository(),
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
@@ -53,7 +58,12 @@ class ContactSearchViewModel(
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration)
val pagedDataSource = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig)
}
@@ -61,6 +71,10 @@ class ContactSearchViewModel(
configurationStore.update { it.copy(query = query) }
}
fun setConversationFilterRequest(conversationFilterRequest: ConversationFilterRequest) {
configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
@@ -98,7 +112,7 @@ class ContactSearchViewModel(
return selectionStore.state
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey.Story>) {
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe {
configurationStore.update { state ->
state.copy(
@@ -139,10 +153,22 @@ class ContactSearchViewModel(
class Factory(
private val selectionLimits: SelectionLimits,
private val repository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository, performSafetyNumberChecks)) as T
return modelClass.cast(
ContactSearchViewModel(
selectionLimits = selectionLimits,
contactSearchRepository = repository,
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
) as T
}
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import org.thoughtcrime.securesms.R
/**
* Groups in common helper class
*/
data class GroupsInCommon(
private val total: Int,
private val names: List<String>
) {
fun toDisplayText(context: Context): String {
return when (total) {
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, names[0])
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, names[0], names[1])
else -> context.getString(
R.string.MessageRequestProfileView_member_of_many_groups, names[0], names[1],
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, total - 2, total - 2)
)
}
}
}

View File

@@ -77,8 +77,8 @@ class SafetyNumberRepository(
private fun List<ContactSearchKey>.flattenToRecipientIds(): Set<RecipientId> {
return this
.map {
when (it) {
is ContactSearchKey.RecipientSearchKey.KnownRecipient -> {
when {
it is ContactSearchKey.RecipientSearchKey && !it.isStory -> {
val recipient = Recipient.resolved(it.recipientId)
if (recipient.isGroup) {
recipient.participantIds
@@ -86,7 +86,7 @@ class SafetyNumberRepository(
listOf(it.recipientId)
}
}
is ContactSearchKey.RecipientSearchKey.Story -> Recipient.resolved(it.recipientId).participantIds
it is ContactSearchKey.RecipientSearchKey -> Recipient.resolved(it.recipientId).participantIds
else -> throw AssertionError("Invalid contact selection $it")
}
}

View File

@@ -34,8 +34,11 @@ open class ConversationActivity : PassphraseRequiredActivity(), ConversationPare
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
shareDataTimestamp = savedInstanceState?.getLong(STATE_WATERMARK, -1L) ?: -1L
if (savedInstanceState != null) {
shareDataTimestamp = savedInstanceState.getLong(STATE_WATERMARK, -1L)
} else if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) {
shareDataTimestamp = System.currentTimeMillis()
}
setContentView(R.layout.conversation_parent_fragment_container)
if (savedInstanceState == null) {

View File

@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
@@ -120,6 +121,7 @@ public class ConversationAdapter
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@@ -261,6 +263,11 @@ public class ConversationAdapter
notifyDataSetChanged();
}
public void setScheduledMessagesMode(boolean scheduledMessagesMode) {
this.scheduledMessagesMode = scheduledMessagesMode;
notifyDataSetChanged();
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
@@ -331,7 +338,11 @@ public class ConversationAdapter
if (conversationMessage == null) return -1;
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
if (scheduledMessagesMode) {
calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
} else {
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
}
return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR);
}
@@ -345,7 +356,11 @@ public class ConversationAdapter
Context context = viewHolder.itemView.getContext();
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
if (scheduledMessagesMode) {
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
} else {
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
}
if (type == HEADER_TYPE_POPOVER_DATE) {
if (hasWallpaper) {

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.conversation
import org.thoughtcrime.securesms.database.model.MessageRecord
/**
* Callback interface for bottom sheets that show conversation data in a conversation and
* want to manipulate the conversation view.
*/
interface ConversationBottomSheetCallback {
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
fun jumpToMessage(messageRecord: MessageRecord)
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
@@ -36,9 +35,7 @@ class ConversationContextMenu(private val anchor: View, items: List<ActionItem>)
isFocusable = false
isOutsideTouchable = true
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
elevation = 20f
setTouchInterceptor { _, event ->
event.action == MotionEvent.ACTION_OUTSIDE

View File

@@ -166,7 +166,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
stopwatch.split("recipient-resolves");
List<ConversationMessage> messages = Stream.of(records)
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId())))
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, m.getDisplayBody(context), mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId())))
.toList();
stopwatch.split("conversion");
@@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
return null;
}
if (record instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) record).getScheduledDate() != -1) {
return null;
}
stopwatch.split("message");
try {
@@ -220,7 +224,11 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
stopwatch.split("calls");
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record, mentions, isQuoted);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(),
record,
record.getDisplayBody(ApplicationDependencies.getApplication()),
mentions,
isQuoted);
} else {
return null;
}

View File

@@ -207,7 +207,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback, MessageQuotesBottomSheet.Callback {
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback, ConversationBottomSheetCallback {
private static final String TAG = Log.tag(ConversationFragment.class);
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
@@ -1188,15 +1188,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
Toast.LENGTH_LONG).show();
}
public long stageOutgoingMessage(OutgoingMessage message) {
public void stageOutgoingMessage(OutgoingMessage message) {
if (message.getScheduledDate() != -1) {
return;
}
MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent();
if (getListAdapter() != null) {
setLastSeen(0);
list.post(() -> list.scrollToPosition(0));
}
return messageRecord.getId();
}
private void presentConversationMetadata(@NonNull ConversationData conversation) {
@@ -2114,6 +2115,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onSendPaymentClicked(@NonNull RecipientId recipientId) {
AttachmentManager.selectPayment(ConversationFragment.this, recipient.get());
}
@Override
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) {
}
}
private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) {

View File

@@ -181,6 +181,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private static final long MAX_CLUSTERING_TIME_DIFF = TimeUnit.MINUTES.toMillis(3);
private static final int CONDENSED_MODE_MAX_LINES = 3;
private static final SearchUtil.StyleFactory STYLE_FACTORY = () -> new CharacterStyle[] { new BackgroundColorSpan(Color.YELLOW), new ForegroundColorSpan(Color.BLACK) };
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Optional<MessageRecord> nextMessageRecord;
@@ -208,6 +210,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private View storyReactionLabelWrapper;
private TextView storyReactionLabel;
protected View quotedIndicator;
protected View scheduledIndicator;
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -231,18 +234,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private int measureCalls;
private boolean updatingFooter;
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
private final ScheduledIndicatorClickListener scheduledIndicatorClickListener = new ScheduledIndicatorClickListener();
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
private final Context context;
@@ -276,6 +280,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR);
}
if (scheduledIndicator != null) {
scheduledIndicator.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR);
}
}
};
@@ -326,6 +335,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
this.quotedIndicator = findViewById(R.id.quoted_indicator);
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
setOnClickListener(new ClickListener(null));
@@ -393,6 +403,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
setStoryReactionLabel(messageRecord);
setHasBeenQuoted(conversationMessage);
setHasBeenScheduled(conversationMessage);
if (audioViewStub.resolved()) {
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
@@ -988,8 +999,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRequestAccepted) {
linkifyMessageBody(styledText, batchSelected.isEmpty());
}
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery, SearchUtil.STRICT);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery, SearchUtil.STRICT);
styledText = SearchUtil.getHighlightedSpan(locale, STYLE_FACTORY, styledText, searchQuery, SearchUtil.STRICT);
if (hasExtraText(messageRecord)) {
bodyText.setOverflowText(getLongMessageSpan(messageRecord));
@@ -1034,7 +1044,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean messageRequestAccepted,
boolean allowedToPlayInline)
{
boolean showControls = !messageRecord.isFailed();
boolean showControls = !messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord);
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding));
@@ -1710,6 +1720,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setHasBeenScheduled(@NonNull ConversationMessage message) {
if (scheduledIndicator == null) {
return;
}
if (message.hasBeenScheduled()) {
scheduledIndicator.setVisibility(View.VISIBLE);
scheduledIndicator.setOnClickListener(scheduledIndicatorClickListener);
} else {
scheduledIndicator.setVisibility(View.GONE);
scheduledIndicator.setOnClickListener(null);
}
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
}
@@ -1871,21 +1894,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
if (isGroupThread) {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
} else {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) ||
MessageRecordUtil.isScheduled(current);
}
}
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (isGroupThread) {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
MessageRecordUtil.isScheduled(current);
} else {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
!isWithinClusteringTime(current, next.get());
!isWithinClusteringTime(current, next.get()) || MessageRecordUtil.isScheduled(current);
}
}
@@ -2278,6 +2303,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class ScheduledIndicatorClickListener implements View.OnClickListener {
public void onClick(final View view) {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onScheduledIndicatorClicked(view, (messageRecord));
} else {
passthroughClickListener.onClick(view);
}
}
}
private class AttachmentDownloadClickListener implements SlidesClickedListener {
@Override
public void onClick(View v, final List<Slide> slides) {

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
@@ -12,6 +11,7 @@ import org.signal.core.util.Conversions;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
import org.thoughtcrime.securesms.database.BodyRangeUtil;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
@@ -35,22 +35,20 @@ public class ConversationMessage {
@NonNull private final MessageStyler.Result styleResult;
private final boolean hasBeenQuoted;
private ConversationMessage(@NonNull MessageRecord messageRecord, boolean hasBeenQuoted) {
this(messageRecord, null, null, hasBeenQuoted);
}
private ConversationMessage(@NonNull MessageRecord messageRecord,
@Nullable CharSequence body,
@Nullable List<Mention> mentions,
boolean hasBeenQuoted)
boolean hasBeenQuoted,
@Nullable MessageStyler.Result styleResult)
{
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
this.mentions = mentions != null ? mentions : Collections.emptyList();
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
if (body != null) {
this.body = SpannableString.valueOf(body);
} else if (messageRecord.hasMessageRanges()) {
} else if (messageRecord.getMessageRanges() != null) {
this.body = SpannableString.valueOf(messageRecord.getBody());
} else {
this.body = null;
@@ -60,12 +58,6 @@ public class ConversationMessage {
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
}
if (this.body != null && messageRecord.hasMessageRanges()) {
styleResult = MessageStyler.style(messageRecord.requireMessageRanges(), this.body);
} else {
styleResult = MessageStyler.Result.none();
}
multiselectCollection = Multiselect.getParts(this);
}
@@ -123,37 +115,15 @@ public class ConversationMessage {
getBottomButton() == null;
}
public boolean hasBeenScheduled() {
return MessageRecordUtil.isScheduled(messageRecord);
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/
public static class ConversationMessageFactory {
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord. No database or
* heavy work performed as the message is assumed to not have any mentions.
*/
@AnyThread
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, boolean hasBeenQuoted) {
return new ConversationMessage(messageRecord, hasBeenQuoted);
}
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord, potentially annotated body, and
* list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be
* fully updated with display names.
*
* @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names.
* @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body.
* @param hasBeenQuoted Whether or not the message has been quoted by another message.
*/
@AnyThread
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<Mention> mentions, boolean hasBeenQuoted) {
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
return new ConversationMessage(messageRecord, body, mentions, hasBeenQuoted);
}
return new ConversationMessage(messageRecord, body, null, hasBeenQuoted);
}
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord and will update and modify the provided
* mentions from placeholder to actual. This method may perform database operations to resolve mentions to display names.
@@ -161,12 +131,33 @@ public class ConversationMessage {
* @param mentions List of placeholder mentions to be used to update the body in the provided MessageRecord.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List<Mention> mentions, boolean hasBeenQuoted) {
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions);
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted);
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context,
@NonNull MessageRecord messageRecord,
@NonNull CharSequence body,
@Nullable List<Mention> mentions,
boolean hasBeenQuoted)
{
SpannableString styledAndMentionBody = null;
MessageStyler.Result styleResult = MessageStyler.Result.none();
MentionUtil.UpdatedBodyAndMentions mentionsUpdate = null;
if (mentions != null && !mentions.isEmpty()) {
mentionsUpdate = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions);
}
return createWithResolvedData(messageRecord, hasBeenQuoted);
if (messageRecord.getMessageRanges() != null) {
BodyRangeList bodyRanges = mentionsUpdate == null ? messageRecord.getMessageRanges()
: BodyRangeUtil.adjustBodyRanges(messageRecord.getMessageRanges(), mentionsUpdate.getBodyAdjustments());
styledAndMentionBody = SpannableString.valueOf(mentionsUpdate != null ? mentionsUpdate.getBody() : body);
styleResult = MessageStyler.style(bodyRanges, styledAndMentionBody);
}
return new ConversationMessage(messageRecord,
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
mentionsUpdate != null ? mentionsUpdate.getMentions() : null,
hasBeenQuoted,
styleResult);
}
/**
@@ -185,17 +176,10 @@ public class ConversationMessage {
* database operations to query for mentions and then to resolve mentions to display names.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) {
boolean hasBeenQuoted = SignalDatabase.messages().isQuoted(messageRecord);
if (messageRecord.isMms()) {
List<Mention> mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId());
if (!mentions.isEmpty()) {
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions);
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted);
}
}
return createWithResolvedData(messageRecord, body, null, hasBeenQuoted);
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, boolean hasBeenQuoted) {
List<Mention> mentions = messageRecord.isMms() ? SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId())
: null;
return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context), mentions, hasBeenQuoted);
}
/**
@@ -204,15 +188,11 @@ public class ConversationMessage {
* database operations to query for mentions and then to resolve mentions to display names.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body, boolean hasBeenQuoted) {
if (messageRecord.isMms()) {
List<Mention> mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId());
if (!mentions.isEmpty()) {
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions);
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted);
}
}
return createWithResolvedData(messageRecord, body, null, hasBeenQuoted);
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) {
boolean hasBeenQuoted = SignalDatabase.messages().isQuoted(messageRecord);
List<Mention> mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId());
return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted);
}
}
}

View File

@@ -84,7 +84,6 @@ import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.view.MenuItemCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.RecyclerView;
@@ -178,6 +177,7 @@ import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState;
import org.thoughtcrime.securesms.database.SignalDatabase;
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;
@@ -187,6 +187,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.database.model.StoryType;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.GroupCallPeekEvent;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
@@ -284,6 +285,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
@@ -326,12 +328,13 @@ import java.util.concurrent.atomic.AtomicInteger;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
import org.thoughtcrime.securesms.database.model.GroupRecord;
/**
* Fragment for displaying a message thread, as well as
* composing/sending a new message into that thread.
@@ -361,7 +364,9 @@ public class ConversationParentFragment extends Fragment
EmojiSearchFragment.Callback,
StickerKeyboardPageFragment.Callback,
Material3OnScrollHelperBinder,
MessageDetailsFragment.Callback
MessageDetailsFragment.Callback,
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
ConversationBottomSheetCallback
{
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
@@ -416,6 +421,7 @@ public class ConversationParentFragment extends Fragment
private AttachmentManager attachmentManager;
private AudioRecorder audioRecorder;
private RecordingSession recordingSession;
private BroadcastReceiver securityUpdateReceiver;
private Stub<MediaKeyboard> emojiDrawerStub;
private Stub<AttachmentKeyboard> attachmentKeyboardStub;
@@ -428,6 +434,7 @@ public class ConversationParentFragment extends Fragment
private View cancelJoinRequest;
private Stub<View> releaseChannelUnmute;
private Stub<View> mentionsSuggestions;
private Stub<View> scheduledMessagesBarStub;
private MaterialButton joinGroupCallButton;
private boolean callingTooltipShown;
private ImageView wallpaper;
@@ -458,6 +465,7 @@ public class ConversationParentFragment extends Fragment
private int distributionType;
private int reactWithAnyEmojiStartPage = -1;
private boolean isSearchRequested = false;
private boolean reshowScheduleMessagesBar = false;
private final LifecycleDisposable disposables = new LifecycleDisposable();
private final Debouncer optionsMenuDebouncer = new Debouncer(50);
@@ -553,6 +561,7 @@ public class ConversationParentFragment extends Fragment
initializeActionBar();
disposables.add(viewModel.getStoryViewState().subscribe(titleView::setStoryRingFromState));
disposables.add(viewModel.getScheduledMessageCount().subscribe(this::updateScheduledMessagesBar));
backPressedCallback = new OnBackPressedCallback(true) {
@Override
@@ -746,11 +755,12 @@ public class ConversationParentFragment extends Fragment
return;
}
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
boolean initiating = threadId == -1;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
SlideDeck slideDeck = new SlideDeck();
List<Mention> mentions = new ArrayList<>(result.getMentions());
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
boolean initiating = threadId == -1;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
SlideDeck slideDeck = new SlideDeck();
List<Mention> mentions = new ArrayList<>(result.getMentions());
BodyRangeList bodyRanges = result.getBodyRanges();
for (Media mediaItem : result.getNonUploadedMedia()) {
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
@@ -774,11 +784,13 @@ public class ConversationParentFragment extends Fragment
Collections.emptyList(),
Collections.emptyList(),
mentions,
bodyRanges,
expiresIn,
result.isViewOnce(),
initiating,
true,
null).addListener(new AssertedSuccessListener<Void>() {
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -1570,7 +1582,7 @@ public class ConversationParentFragment extends Fragment
SafetyNumberBottomSheet
.forIdentityRecordsAndDestination(
records,
new ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.getId())
new ContactSearchKey.RecipientSearchKey(recipient.getId(), false)
)
.show(getChildFragmentManager());
}
@@ -1838,7 +1850,7 @@ public class ConversationParentFragment extends Fragment
quoteResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
case Draft.MENTION:
case Draft.BODY_RANGES:
listener.onSuccess(true);
break;
}
@@ -1936,8 +1948,6 @@ public class ConversationParentFragment extends Fragment
InsightsLauncher.showInsightsDashboard(getChildFragmentManager());
} else if (reminderActionId == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
} else if (reminderActionId == R.id.reminder_action_api_19_learn_more) {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/5109141421850");
} else {
throw new IllegalArgumentException("Unknown ID: " + reminderActionId);
}
@@ -2036,6 +2046,7 @@ public class ConversationParentFragment extends Fragment
wallpaperDim = view.findViewById(R.id.conversation_wallpaper_dim);
voiceNotePlayerViewStub = ViewUtil.findStubById(view, R.id.voice_note_player_stub);
navigationBarBackground = view.findViewById(R.id.navbar_background);
scheduledMessagesBarStub = ViewUtil.findStubById(view, R.id.scheduled_messages_stub);
ImageButton quickCameraToggle = view.findViewById(R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = view.findViewById(R.id.inline_attachment_button);
@@ -2070,6 +2081,26 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
if (FeatureFlags.scheduledMessageSends()) {
sendButton.setScheduledSendListener(new SendButton.ScheduledSendListener() {
@Override
public void onSendScheduled() {
ScheduleMessageContextMenu.show(sendButton, (ViewGroup) requireView(), time -> {
if (time == -1) {
ScheduleMessageTimePickerBottomSheet.showSchedule(getChildFragmentManager());
} else {
sendMessage(null, time);
}
return Unit.INSTANCE;
});
}
@Override
public boolean canSchedule() {
return !(inputPanel.isRecordingInLockedMode() || draftViewModel.getVoiceNoteDraft() != null);
}
});
}
sendButton.setEnabled(true);
sendButton.addOnSelectionChangedListener((newMessageSendType, manuallySelected) -> {
if (getContext() == null) {
@@ -2459,7 +2490,7 @@ public class ConversationParentFragment extends Fragment
}
private void showGroupCallingTooltip() {
if (Build.VERSION.SDK_INT == 19 || !SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) {
if (!SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) {
return;
}
@@ -2704,7 +2735,7 @@ public class ConversationParentFragment extends Fragment
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
boolean initiating = threadId == -1;
sendMediaMessage(recipient.getId(), sendButton.getSelectedSendType(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, initiating, false, null);
sendMediaMessage(recipient.getId(), sendButton.getSelectedSendType(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), null, expiresIn, false, initiating, false, null);
}
private void selectContactInfo(ContactData contactData) {
@@ -2749,13 +2780,7 @@ public class ConversationParentFragment extends Fragment
MaterialButton actionButton = smsExportStub.get().findViewById(R.id.export_sms_button);
boolean isPhase1 = SignalStore.misc().getSmsExportPhase() == SmsExportPhase.PHASE_1;
if (SignalStore.misc().getSmsExportPhase() == SmsExportPhase.PHASE_0) {
message.setText(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, recipient.getDisplayName(requireContext())));
actionButton.setText(R.string.conversation_activity__enable_signal_for_sms);
actionButton.setOnClickListener(v -> {
handleMakeDefaultSms();
});
} else if (conversationSecurityInfo.getHasUnexportedInsecureMessages()) {
if (conversationSecurityInfo.getHasUnexportedInsecureMessages()) {
message.setText(isPhase1 ? R.string.ConversationActivity__sms_messaging_is_currently_disabled_you_can_export_your_messages_to_another_app_on_your_phone
: R.string.ConversationActivity__sms_messaging_is_no_longer_supported_in_signal_you_can_export_your_messages_to_another_app_on_your_phone);
actionButton.setText(R.string.ConversationActivity__export_sms_messages);
@@ -2919,6 +2944,13 @@ public class ConversationParentFragment extends Fragment
}
private void sendMessage(@Nullable String metricId) {
sendMessage(metricId, -1);
}
private void sendMessage(@Nullable String metricId, long scheduledDate) {
if (scheduledDate != -1) {
ReenableScheduledMessagesDialogFragment.showIfNeeded(requireContext(), getChildFragmentManager());
}
if (inputPanel.isRecordingInLockedMode()) {
inputPanel.releaseRecordingLock();
return;
@@ -2928,7 +2960,7 @@ public class ConversationParentFragment extends Fragment
if (voiceNote != null) {
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote);
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize(), scheduledDate);
return;
}
@@ -2949,6 +2981,7 @@ public class ConversationParentFragment extends Fragment
recipient.getEmail().isPresent() ||
inputPanel.getQuote().isPresent() ||
composeText.hasMentions() ||
composeText.hasStyling() ||
linkPreviewViewModel.hasLinkPreview() ||
needsSplit;
@@ -2959,9 +2992,9 @@ public class ConversationParentFragment extends Fragment
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@@ -3003,9 +3036,11 @@ public class ConversationParentFragment extends Fragment
Collections.emptySet(),
Collections.emptySet(),
null,
true);
true,
result.getBodyRanges(),
-1);
final Context context = requireContext().getApplicationContext();
final Context context = requireContext().getApplicationContext();
ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread);
@@ -3013,7 +3048,7 @@ public class ConversationParentFragment extends Fragment
attachmentManager.clear(glideRequests, false);
silentlySetComposeText("");
long id = fragment.stageOutgoingMessage(message);
fragment.stageOutgoingMessage(message);
SimpleTask.run(() -> {
long resultId = MessageSender.sendPushWithPreUploadedMedia(context, message, result.getPreUploadResults(), thread, null);
@@ -3025,7 +3060,7 @@ public class ConversationParentFragment extends Fragment
}, this::sendComplete);
}
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -3038,11 +3073,13 @@ public class ConversationParentFragment extends Fragment
Collections.emptyList(),
linkPreviews,
composeText.getMentions(),
composeText.getStyling(),
expiresIn,
viewOnce,
initiating,
true,
metricId);
metricId,
scheduledDate);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -3053,11 +3090,31 @@ public class ConversationParentFragment extends Fragment
List<Contact> contacts,
List<LinkPreview> previews,
List<Mention> mentions,
@Nullable BodyRangeList styling,
final long expiresIn,
final boolean viewOnce,
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@NonNull MessageSendType sendType,
@NonNull String body,
SlideDeck slideDeck,
QuoteModel quote,
List<Contact> contacts,
List<LinkPreview> previews,
List<Mention> mentions,
@Nullable BodyRangeList styling,
final long expiresIn,
final boolean viewOnce,
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
{
if (ExpiredBuildReminder.isEligible()) {
showExpiredDialog();
@@ -3099,7 +3156,9 @@ public class ConversationParentFragment extends Fragment
Collections.emptySet(),
Collections.emptySet(),
null,
false);
false,
styling,
scheduledDate);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@@ -3124,7 +3183,7 @@ public class ConversationParentFragment extends Fragment
silentlySetComposeText("");
}
final long id = fragment.stageOutgoingMessage(outgoingMessage);
fragment.stageOutgoingMessage(outgoingMessage);
SimpleTask.run(() -> {
return MessageSender.send(context, outgoingMessage, thread, sendType.usesSmsTransport() ? SendType.MMS : SendType.SIGNAL, metricId, null);
@@ -3139,7 +3198,7 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId)
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
throws InvalidMessageException
{
if (ExpiredBuildReminder.isEligible()) {
@@ -3157,10 +3216,14 @@ public class ConversationParentFragment extends Fragment
final String messageBody = getMessage();
final boolean sendPush = sendType.usesSignalTransport();
OutgoingMessage message;
final OutgoingMessage message;
if (sendPush) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis());
if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
} else {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
}
ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread);
} else {
message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1));
@@ -3203,8 +3266,6 @@ public class ConversationParentFragment extends Fragment
builder.setNeutralButton(action.getTitle(), (d, i) -> {
if (action.getActionId() == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
} else if (action.getActionId() == R.id.reminder_action_api_19_learn_more) {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/5109141421850");
}
});
}
@@ -3260,6 +3321,24 @@ public class ConversationParentFragment extends Fragment
}
}
private void updateScheduledMessagesBar(int count) {
if (count <= 0) {
scheduledMessagesBarStub.setVisibility(View.GONE);
reshowScheduleMessagesBar = false;
} else {
View scheduledMessagesBar = scheduledMessagesBarStub.get();
scheduledMessagesBar.findViewById(R.id.scheduled_messages_show_all).setOnClickListener(v -> {
ScheduledMessagesBottomSheet.show(getChildFragmentManager(), threadId, recipient.getId());
});
scheduledMessagesBar.setVisibility(View.VISIBLE);
reshowScheduleMessagesBar = true;
TextView scheduledText = scheduledMessagesBar.findViewById(R.id.scheduled_messages_text);
scheduledText.setText(getResources().getQuantityString(R.plurals.conversation_scheduled_messages_bar__number_of_messages, count, count));
}
}
private void recordTransportPreference(MessageSendType sendType) {
new AsyncTask<Void, Void, Void>() {
@Override
@@ -3296,7 +3375,9 @@ public class ConversationParentFragment extends Fragment
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED);
voiceNoteMediaController.pausePlayback();
audioRecorder.startRecording();
recordingSession = new RecordingSession(audioRecorder.startRecording());
disposables.add(recordingSession);
}
@Override
@@ -3316,22 +3397,11 @@ public class ConversationParentFragment extends Fragment
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
future.addListener(new ListenableFuture.Listener<VoiceNoteDraft>() {
@Override
public void onSuccess(final @NonNull VoiceNoteDraft result) {
sendVoiceNote(result.getUri(), result.getSize());
}
@Override
public void onFailure(ExecutionException e) {
Toast.makeText(requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show();
}
});
recordingSession.completeRecording();
}
@Override
public void onRecorderCanceled() {
public void onRecorderCanceled(boolean byUser) {
voiceRecorderWakeLock.release();
updateToggleButtonState();
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
@@ -3340,11 +3410,12 @@ public class ConversationParentFragment extends Fragment
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
future.addListener(new DeleteCanceledVoiceNoteListener());
} else {
draftViewModel.saveEphemeralVoiceNoteDraft(future);
if (recordingSession != null) {
if (byUser) {
recordingSession.discardRecording();
} else {
recordingSession.saveDraft();
}
}
}
@@ -3413,7 +3484,7 @@ public class ConversationParentFragment extends Fragment
container.hideAttachedInput(true);
}
private void sendVoiceNote(@NonNull Uri uri, long size) {
private void sendVoiceNote(@NonNull Uri uri, long size, long scheduledDate) {
boolean initiating = threadId == -1;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
@@ -3429,11 +3500,13 @@ public class ConversationParentFragment extends Fragment
Collections.emptyList(),
Collections.emptyList(),
composeText.getMentions(),
composeText.getStyling(),
expiresIn,
false,
initiating,
true,
null);
null,
scheduledDate);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@@ -3461,7 +3534,7 @@ public class ConversationParentFragment extends Fragment
slideDeck.addSlide(stickerSlide);
sendMediaMessage(recipient.getId(), sendType, "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, initiating, clearCompose, null);
sendMediaMessage(recipient.getId(), sendType, "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null, expiresIn, false, initiating, clearCompose, null);
}
private void silentlySetComposeText(String text) {
@@ -3588,7 +3661,7 @@ public class ConversationParentFragment extends Fragment
}
@Override
public void sendAnywayAfterSafetyNumberChangedInBottomSheet(@NonNull List<? extends ContactSearchKey.RecipientSearchKey> destinations) {
public void sendAnywayAfterSafetyNumberChangedInBottomSheet(@NonNull List<ContactSearchKey.RecipientSearchKey> destinations) {
Log.d(TAG, "onSendAnywayAfterSafetyNumberChange");
initializeIdentityRecords().addListener(new AssertedSuccessListener<Boolean>() {
@Override
@@ -3598,16 +3671,87 @@ public class ConversationParentFragment extends Fragment
});
}
public void onScheduleSend(long scheduledTime) {
sendMessage(null, scheduledTime);
}
@Override
public @NonNull ConversationAdapter.ItemClickListener getConversationAdapterListener() {
return fragment.getConversationAdapterListener();
}
@Override
public void jumpToMessage(@NonNull MessageRecord messageRecord) {
fragment.jumpToMessage(messageRecord);
}
// Listeners
private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener<VoiceNoteDraft> {
@Override
public void onSuccess(final VoiceNoteDraft result) {
draftViewModel.cancelEphemeralVoiceNoteDraft(result.asDraft());
private class RecordingSession implements SingleObserver<VoiceNoteDraft>, Disposable {
private boolean saveDraft = true;
private boolean shouldSend = false;
private Disposable disposable = Disposable.empty();
RecordingSession(Single<VoiceNoteDraft> observable) {
observable.observeOn(AndroidSchedulers.mainThread()).subscribe(this);
}
@Override
public void onFailure(ExecutionException e) {}
public void onSubscribe(@io.reactivex.rxjava3.annotations.NonNull Disposable d) {
this.disposable = d;
}
@Override
public void onSuccess(@NonNull VoiceNoteDraft draft) {
if (shouldSend) {
sendVoiceNote(draft.getUri(), draft.getSize(), -1);
} else {
if (!saveDraft) {
draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft());
} else {
draftViewModel.saveEphemeralVoiceNoteDraft(draft.asDraft());
}
}
recordingSession.dispose();
recordingSession = null;
}
@Override
public void onError(Throwable t) {
Toast.makeText(requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show();
Log.e(TAG, "Error in RecordingSession.", t);
recordingSession.dispose();
recordingSession = null;
}
public void saveDraft() {
this.saveDraft = true;
this.shouldSend = false;
audioRecorder.stopRecording();
}
public void discardRecording() {
this.saveDraft = false;
this.shouldSend = false;
audioRecorder.stopRecording();
}
public void completeRecording() {
this.shouldSend = true;
audioRecorder.stopRecording();
}
@Override
public void dispose() {
disposable.dispose();
}
@Override
public boolean isDisposed() {
return disposable.isDisposed();
}
}
private class QuickCameraToggleListener implements OnClickListener {
@@ -3656,7 +3800,7 @@ public class ConversationParentFragment extends Fragment
private class AttachButtonLongClickListener implements View.OnLongClickListener {
@Override
public boolean onLongClick(View v) {
return sendButton.performLongClick();
return sendButton.showSendTypeMenu();
}
}
@@ -3718,12 +3862,12 @@ public class ConversationParentFragment extends Fragment
@Override
public void onTextChanged(@NonNull CharSequence text) {
handleSaveDraftOnTextChange(text);
handleSaveDraftOnTextChange(composeText.getTextTrimmed());
handleTypingIndicatorOnTextChange(text.toString());
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text)));
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text)));
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
@@ -4038,11 +4182,19 @@ public class ConversationParentFragment extends Fragment
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();
toolbar.setVisibility(View.GONE);
if (scheduledMessagesBarStub.getVisibility() == View.VISIBLE) {
reshowScheduleMessagesBar = true;
scheduledMessagesBarStub.setVisibility(View.GONE);
}
}
@Override
public void onMessageActionToolbarClosed() {
toolbar.setVisibility(View.VISIBLE);
if (reshowScheduleMessagesBar) {
scheduledMessagesBarStub.setVisibility(View.VISIBLE);
reshowScheduleMessagesBar = false;
}
}
@Override
@@ -4162,6 +4314,7 @@ public class ConversationParentFragment extends Fragment
Collections.emptyList(),
Collections.emptyList(),
composeText.getMentions(),
composeText.getStyling(),
expiresIn,
false,
initiating,

View File

@@ -166,16 +166,11 @@ public final class ConversationReactionOverlay extends FrameLayout {
setupSelectedEmoji();
if (Build.VERSION.SDK_INT >= 21) {
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
View navigationBarBackground = activity.findViewById(android.R.id.navigationBarBackground);
bottomNavigationBarHeight = navigationBarBackground == null ? 0 : navigationBarBackground.getHeight();
} else {
statusBarHeight = ViewUtil.getStatusBarHeight(this);
bottomNavigationBarHeight = ViewUtil.getNavigationBarHeight(this);
}
View navigationBarBackground = activity.findViewById(android.R.id.navigationBarBackground);
bottomNavigationBarHeight = navigationBarBackground == null ? 0 : navigationBarBackground.getHeight();
if (zeroNavigationBarHeightForConfiguration()) {
bottomNavigationBarHeight = 0;
@@ -199,10 +194,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
setVisibility(View.INVISIBLE);
if (Build.VERSION.SDK_INT >= 21) {
this.activity = activity;
updateSystemUiOnShow(activity);
}
this.activity = activity;
updateSystemUiOnShow(activity);
ViewKt.doOnLayout(this, v -> {
showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
@@ -908,7 +901,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
inputShadeAnim.setDuration(duration);
animators.add(inputShadeAnim);
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
if (activity != null) {
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
statusBarAnim.setDuration(duration);
statusBarAnim.addUpdateListener(animation -> {

View File

@@ -73,6 +73,7 @@ public class ConversationViewModel extends ViewModel {
private final Application context;
private final MediaRepository mediaRepository;
private final ConversationRepository conversationRepository;
private final ScheduledMessagesRepository scheduledMessagesRepository;
private final MutableLiveData<List<Media>> recentMedia;
private final BehaviorSubject<Long> threadId;
private final Observable<MessageData> messageData;
@@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel {
private final CompositeDisposable disposables;
private final BehaviorSubject<Unit> conversationStateTick;
private final PublishProcessor<Long> markReadRequestPublisher;
private final Observable<Integer> scheduledMessageCount;
private ConversationIntents.Args args;
private int jumpToPosition;
@@ -107,6 +109,7 @@ public class ConversationViewModel extends ViewModel {
this.context = ApplicationDependencies.getApplication();
this.mediaRepository = new MediaRepository();
this.conversationRepository = new ConversationRepository();
this.scheduledMessagesRepository = new ScheduledMessagesRepository();
this.recentMedia = new MutableLiveData<>();
this.showScrollButtons = new MutableLiveData<>(false);
this.hasUnreadMentions = new MutableLiveData<>(false);
@@ -200,6 +203,10 @@ public class ConversationViewModel extends ViewModel {
.withLatestFrom(conversationMetadata, (messages, metadata) -> new MessageData(metadata, messages))
.doOnNext(a -> SignalLocalMetrics.ConversationOpen.onDataLoaded());
scheduledMessageCount = threadId
.observeOn(Schedulers.io())
.switchMap(scheduledMessagesRepository::getScheduledMessageCount);
Observable<Recipient> liveRecipient = recipientId.distinctUntilChanged().switchMap(id -> Recipient.live(id).asObservable());
canShowAsBubble = threadId.observeOn(Schedulers.io()).map(conversationRepository::canShowAsBubble);
@@ -371,6 +378,10 @@ public class ConversationViewModel extends ViewModel {
.observeOn(AndroidSchedulers.mainThread());
}
@NonNull Observable<Integer> getScheduledMessageCount() {
return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread());
}
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}
@@ -444,9 +455,7 @@ public class ConversationViewModel extends ViewModel {
}
public void insertSmsExportUpdateEvent(@NonNull Recipient recipient) {
if (SignalStore.misc().getSmsExportPhase().isAtLeastPhase1()) {
conversationRepository.insertSmsExportUpdateEvent(recipient);
}
conversationRepository.insertSmsExportUpdateEvent(recipient);
}
enum Event {

View File

@@ -220,7 +220,8 @@ final class MenuState {
messageRecord.isChangeNumber() ||
messageRecord.isBoostRequest() ||
messageRecord.isPaymentsRequestToActivate() ||
messageRecord.isPaymentsActivated();
messageRecord.isPaymentsActivated() ||
messageRecord.isSmsExportType();
}
private final static class Builder {

View File

@@ -1,45 +1,137 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Typeface
import android.text.SpannableString
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.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
/**
* Helper for applying style-based [BodyRangeList.BodyRange]s to text.
* Helper for parsing and applying styles. Most notably with [BodyRangeList].
*/
object MessageStyler {
const val MONOSPACE = "monospace"
@JvmStatic
fun style(messageRanges: BodyRangeList, span: SpannableString): Result {
fun boldStyle(): CharacterStyle {
return StyleSpan(Typeface.BOLD)
}
@JvmStatic
fun italicStyle(): CharacterStyle {
return StyleSpan(Typeface.ITALIC)
}
@JvmStatic
fun strikethroughStyle(): CharacterStyle {
return StrikethroughSpan()
}
@JvmStatic
fun monoStyle(): CharacterStyle {
return TypefaceSpan(MONOSPACE)
}
@JvmStatic
fun style(messageRanges: BodyRangeList?, span: Spannable): Result {
if (messageRanges == null) {
return Result.none()
}
var appliedStyle = false
var hasLinks = false
var bottomButton: BodyRangeList.BodyRange.Button? = null
for (range in messageRanges.rangesList) {
if (range.hasStyle()) {
messageRanges
.rangesList
.filter { r -> r.start >= 0 && r.start < span.length && r.start + r.length >= 0 && r.start + r.length <= span.length }
.forEach { range ->
if (range.hasStyle()) {
val styleSpan: CharacterStyle? = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
else -> null
}
val styleSpan: CharacterStyle? = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> TypefaceSpan("sans-serif-medium")
BodyRangeList.BodyRange.Style.ITALIC -> StyleSpan(Typeface.ITALIC)
else -> null
if (styleSpan != null) {
span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
appliedStyle = true
}
} else if (range.hasLink() && range.link != null) {
span.setSpan(PlaceholderURLSpan(range.link), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
hasLinks = true
} else if (range.hasButton() && range.button != null) {
bottomButton = range.button
}
if (styleSpan != null) {
span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} else if (range.hasLink() && range.link != null) {
span.setSpan(PlaceholderURLSpan(range.link), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
hasLinks = true
} else if (range.hasButton() && range.button != null) {
bottomButton = range.button
}
return if (appliedStyle || hasLinks || bottomButton != null) {
Result(hasLinks, bottomButton)
} else {
Result.none()
}
}
@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
}
}
@JvmStatic
fun getStyling(text: CharSequence?): BodyRangeList? {
val bodyRanges = if (text is Spanned && FeatureFlags.textFormatting()) {
text
.getSpans(0, text.length, CharacterStyle::class.java)
.filter { s -> isSupportedCharacterStyle(s) }
.mapNotNull { span: CharacterStyle ->
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
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
}
if (spanLength > 0) {
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
} else {
null
}
}
.toList()
} else {
emptyList()
}
return Result(hasLinks, bottomButton)
return if (bodyRanges.isNotEmpty()) {
BodyRangeList.newBuilder().addAllRanges(bodyRanges).build()
} else {
null
}
}
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
return when (style) {
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
is StrikethroughSpan -> true
is TypefaceSpan -> style.family == MONOSPACE
else -> false
}
}
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRangeList.BodyRange.Button? = null) {

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.conversation
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.ScheduleMessageFtuxBottomSheetDialog.Companion.show
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Bottom sheet dialog to prompt user to enable schedule alarms permission for scheduling messages
*/
class ReenableScheduledMessagesDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.reenable_scheduled_messages_dialog_fragment, container, false)
}
@SuppressLint("InlinedApi")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val launcher: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
dismissAllowingStateLoss()
}
}
view.findViewById<View>(R.id.reenable_scheduled_messages_go_to_settings).setOnClickListener {
launcher.launch(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:" + requireContext().packageName)))
}
}
companion object {
@JvmStatic
fun showIfNeeded(context: Context, fragmentManager: FragmentManager) {
var hasPermission = true
if (Build.VERSION.SDK_INT >= 31) {
val alarmManager = ServiceUtil.getAlarmManager(context)
hasPermission = alarmManager.canScheduleExactAlarms()
}
val fragment = if (!SignalStore.uiHints().hasSeenScheduledMessagesInfoSheet()) {
ScheduleMessageFtuxBottomSheetDialog()
} else if (!hasPermission) {
ReenableScheduledMessagesDialogFragment()
} else {
null
}
fragment?.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,78 @@
package org.thoughtcrime.securesms.conversation
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.util.Locale
class ScheduleMessageContextMenu {
companion object {
private val presetHours = arrayOf(8, 12, 18, 21)
@JvmStatic
fun show(anchor: View, container: ViewGroup, action: (Long) -> Unit): SignalContextMenu {
val currentTime = System.currentTimeMillis()
val scheduledTimes = getNextScheduleTimes(currentTime)
val actionItems = scheduledTimes.map {
if (it > 0) {
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
action(it)
}
} else {
ActionItem(R.drawable.ic_calendar_24, anchor.context.getString(R.string.ScheduledMessages_pick_time)) {
action(it)
}
}
}
return SignalContextMenu.Builder(anchor, container)
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(actionItems)
}
@DrawableRes
private fun getIconForTime(timeMs: Long): Int {
val dateTime = timeMs.toLocalDateTime()
return if (dateTime.hour >= 18) {
R.drawable.ic_nighttime_26
} else {
R.drawable.ic_daytime_24
}
}
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
var currentDateTime = currentTimeMs.toLocalDateTime()
val timestampList = ArrayList<Long>(4)
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
if (presetIndex == -1) {
currentDateTime = currentDateTime.plusDays(1)
presetIndex = 0
}
currentDateTime = currentDateTime.withMinute(0).withSecond(0)
while (timestampList.size < 3) {
currentDateTime = currentDateTime.withHour(presetHours[presetIndex])
timestampList += currentDateTime.toMillis()
presetIndex++
if (presetIndex >= presetHours.size) {
presetIndex = 0
currentDateTime = currentDateTime.plusDays(1)
}
}
timestampList += -1
return timestampList.reversed()
}
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.conversation
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ScheduleMessageFtuxBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ServiceUtil
class ScheduleMessageFtuxBottomSheetDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private val binding by ViewBinderDelegate(ScheduleMessageFtuxBottomSheetBinding::bind)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.schedule_message_ftux_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= 31 && !ServiceUtil.getAlarmManager(context).canScheduleExactAlarms()) {
binding.reenableSettings.visibility = View.VISIBLE
binding.okay.visibility = View.GONE
val launcher: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
dismissAllowingStateLoss()
}
}
binding.enableScheduledMessagesGoToSettings.setOnClickListener {
SignalStore.uiHints().markHasSeenScheduledMessagesInfoSheet()
launcher.launch(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:" + requireContext().packageName)))
dismiss()
}
}
binding.okay.setOnClickListener {
SignalStore.uiHints().markHasSeenScheduledMessagesInfoSheet()
dismiss()
}
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = ScheduleMessageFtuxBottomSheetDialog()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,203 @@
package org.thoughtcrime.securesms.conversation
import android.os.Bundle
import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ScheduleMessageTimePickerBottomSheetBinding
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Bottom sheet dialog that allows selecting a timestamp after the current time for
* scheduling a message send.
*
* Will call [ScheduleCallback.onScheduleSend] with the selected time, if called with [showSchedule]
* Will call [RescheduleCallback.onReschedule] with the selected time, if called with [showReschedule]
*/
class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private var scheduledDate: Long = 0
private var scheduledHour: Int = 0
private var scheduledMinute: Int = 0
private val binding by ViewBinderDelegate(ScheduleMessageTimePickerBottomSheetBinding::bind)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.schedule_message_time_picker_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val initialTime = arguments?.getLong(KEY_INITIAL_TIME)
scheduledDate = initialTime ?: System.currentTimeMillis()
var scheduledLocalDateTime = scheduledDate.toLocalDateTime()
if (initialTime == null) {
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
}
scheduledHour = scheduledLocalDateTime.hour
scheduledMinute = scheduledLocalDateTime.minute
binding.scheduleSend.setOnClickListener {
dismiss()
val messageId = arguments?.getLong(KEY_MESSAGE_ID)
if (messageId == null) {
findListener<ScheduleCallback>()?.onScheduleSend(getSelectedTimestamp())
} else {
val selectedTime = getSelectedTimestamp()
if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) {
findListener<RescheduleCallback>()?.onReschedule(selectedTime, messageId)
}
}
}
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
val zonedDateTime = ZonedDateTime.now()
binding.timezoneDisclaimer.apply {
text = getString(
R.string.ScheduleMessageTimePickerBottomSheet__timezone_disclaimer,
zoneOffsetFormatter.format(zonedDateTime),
zoneNameFormatter.format(zonedDateTime),
)
}
updateSelectedDate()
updateSelectedTime()
setupDateSelector()
setupTimeSelector()
}
private fun setupDateSelector() {
binding.daySelector.setOnClickListener {
val local = LocalDateTime.now()
.atMidnight()
.atUTC()
.toMillis()
val datePicker =
MaterialDatePicker.Builder.datePicker()
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title))
.setSelection(scheduledDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
datePicker.addOnDismissListener {
datePicker.clearOnDismissListeners()
datePicker.clearOnPositiveButtonClickListeners()
}
datePicker.addOnPositiveButtonClickListener {
it.let {
scheduledDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
updateSelectedDate()
}
}
datePicker.show(childFragmentManager, "DATE_PICKER")
}
}
private fun setupTimeSelector() {
binding.timeSelector.setOnClickListener {
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePickerFragment = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(scheduledHour)
.setMinute(scheduledMinute)
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_time_title))
.build()
timePickerFragment.addOnDismissListener {
timePickerFragment.clearOnDismissListeners()
timePickerFragment.clearOnPositiveButtonClickListeners()
}
timePickerFragment.addOnPositiveButtonClickListener {
scheduledHour = timePickerFragment.hour
scheduledMinute = timePickerFragment.minute
updateSelectedTime()
}
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
}
}
private fun getSelectedTimestamp(): Long {
return scheduledDate.toLocalDateTime()
.withMinute(scheduledMinute)
.withHour(scheduledHour)
.withSecond(0)
.withNano(0)
.toMillis()
}
private fun updateSelectedDate() {
binding.dateText.text = DateUtils.getDayPrecisionTimeString(requireContext(), Locale.getDefault(), scheduledDate)
}
private fun updateSelectedTime() {
val scheduledTime = LocalTime.of(scheduledHour, scheduledMinute)
binding.timeText.text = scheduledTime.formatHours(requireContext())
}
interface ScheduleCallback {
fun onScheduleSend(scheduledTime: Long)
}
interface RescheduleCallback {
fun onReschedule(scheduledTime: Long, messageId: Long)
}
companion object {
private const val KEY_MESSAGE_ID = "message_id"
private const val KEY_INITIAL_TIME = "initial_time"
@JvmStatic
fun showSchedule(fragmentManager: FragmentManager) {
val fragment = ScheduleMessageTimePickerBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@JvmStatic
fun showReschedule(fragmentManager: FragmentManager, messageId: Long, initialTime: Long) {
val args = Bundle().apply {
putLong(KEY_MESSAGE_ID, messageId)
putLong(KEY_INITIAL_TIME, initialTime)
}
val fragment = ScheduleMessageTimePickerBottomSheet().apply {
arguments = args
}
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,301 @@
package org.thoughtcrime.securesms.conversation
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.TextSlide
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.Locale
/**
* Bottom sheet dialog to view all scheduled messages within a given thread.
*/
class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), ScheduleMessageTimePickerBottomSheet.RescheduleCallback {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private var firstRender: Boolean = true
private var deleteDialog: AlertDialog? = null
private lateinit var messageAdapter: ConversationAdapter
private lateinit var callback: ConversationBottomSheetCallback
private val viewModel: ScheduledMessagesViewModel by viewModels(
factoryProducer = {
val threadId = requireArguments().getLong(KEY_THREAD_ID)
ScheduledMessagesViewModel.Factory(threadId)
}
)
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.scheduled_messages_bottom_sheet, container, false)
disposables.bindTo(viewLifecycleOwner)
return view
}
@SuppressLint("WrongThread")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
callback = requireListener()
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setScheduledMessagesMode(true)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.scheduled_list).apply {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter
itemAnimator = null
doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
}
}
val recyclerViewColorizer = RecyclerViewColorizer(list)
disposables += viewModel.getMessages(requireContext()).subscribe { messages ->
if (messages.isEmpty()) {
deleteDialog?.dismiss()
dismissAllowingStateLoss()
}
messageAdapter.submitList(messages) {
if (firstRender) {
(list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100)
firstRender = false
} else if (!list.canScrollVertically(1)) {
list.layoutManager?.scrollToPosition(0)
}
}
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
initializeGiphyMp4(view.findViewById(R.id.video_container) as ViewGroup, list)
}
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) {
SignalContextMenu.Builder(view, requireCoordinatorLayout())
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(getMenuActionItems(messageRecord))
}
private fun getMenuActionItems(messageRecord: MessageRecord): List<ActionItem> {
val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
val items: MutableList<ActionItem> = ArrayList()
items.add(ActionItem(R.drawable.ic_delete_tinted_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) }))
if (canCopy) {
items.add(ActionItem(R.drawable.ic_copy_24_tinted, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) }))
}
items.add(ActionItem(R.drawable.ic_send_outline_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) }))
items.add(ActionItem(R.drawable.ic_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) }))
return items
}
private fun handleRescheduleMessage(messageRecord: MessageRecord) {
ScheduleMessageTimePickerBottomSheet.showReschedule(childFragmentManager, messageRecord.id, (messageRecord as MediaMmsMessageRecord).scheduledDate)
}
private fun handleSendMessageNow(messageRecord: MessageRecord) {
viewModel.rescheduleMessage(messageRecord.id, System.currentTimeMillis())
}
private fun handleDeleteMessage(messageRecord: MessageRecord) {
deleteDialog?.dismiss()
deleteDialog = buildDeleteScheduledMessageConfirmationDialog(messageRecord)
.setOnDismissListener { deleteDialog = null }
.show()
}
private fun handleCopyMessage(message: ConversationMessage) {
SimpleTask.run(
viewLifecycleOwner.lifecycle,
{ getMessageText(message) },
{ bodies: CharSequence? ->
if (!Util.isEmpty(bodies)) {
Util.copyToClipboard(requireContext(), bodies!!)
}
}
)
}
private fun buildDeleteScheduledMessageConfirmationDialog(messageRecord: MessageRecord): AlertDialog.Builder {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getString(R.string.ScheduledMessagesBottomSheet_delete_dialog_message))
.setCancelable(true)
.setPositiveButton(R.string.ScheduledMessagesBottomSheet_delete_dialog_action) { _: DialogInterface?, _: Int ->
deleteMessage(messageRecord.id)
}
.setNegativeButton(android.R.string.cancel, null)
}
private fun getMessageText(message: ConversationMessage): CharSequence {
if (message.messageRecord.hasTextSlide()) {
val textSlide: TextSlide = message.messageRecord.requireTextSlide()
if (textSlide.uri == null) {
return message.getDisplayBody(requireContext())
}
try {
PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body)
.getDisplayBody(requireContext())
}
} catch (e: IOException) {
Log.w(TAG, "Failed to read text slide data.")
}
}
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext())
}
private fun deleteMessage(messageId: Long) {
context?.let { context ->
val progressDialog = SignalProgressDialog.show(
context = context,
message = resources.getString(R.string.ScheduledMessagesBottomSheet_deleting_progress_message),
indeterminate = true
)
SimpleTask.run({
SignalDatabase.messages.deleteScheduledMessage(messageId)
}, {
progressDialog.dismiss()
})
}
}
override fun onReschedule(scheduledTime: Long, messageId: Long) {
viewModel.rescheduleMessage(messageId, scheduledTime)
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by callback.getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
dismissAllowingStateLoss()
callback.getConversationAdapterListener().onQuoteClicked(messageRecord)
}
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
showScheduledMessageContextMenu(view, messageRecord)
}
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
dismissAllowingStateLoss()
callback.getConversationAdapterListener().onGroupMemberClicked(recipientId, groupId)
}
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
}
companion object {
private val TAG = Log.tag(ScheduledMessagesBottomSheet::class.java)
private const val KEY_THREAD_ID = "thread_id"
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
val args = Bundle().apply {
putLong(KEY_THREAD_ID, threadId)
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
}
val fragment = ScheduledMessagesBottomSheet().apply {
arguments = args
}
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import androidx.annotation.WorkerThread
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.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
/**
* Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment]
*/
class ScheduledMessagesRepository {
/**
* Get all the scheduled messages for the specified thread, ordered by scheduled time
*/
fun getScheduledMessages(context: Context, threadId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getScheduledMessagesSync(context, threadId)) }
databaseObserver.registerScheduledMessageObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getScheduledMessagesSync(context, threadId))
}.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun getScheduledMessagesSync(context: Context, threadId: Long): List<ConversationMessage> {
var scheduledMessages: List<MessageRecord> = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
val attachmentHelper = ConversationDataSource.AttachmentHelper()
attachmentHelper.addAll(scheduledMessages)
attachmentHelper.fetchAttachments()
scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
val replies: List<ConversationMessage> = scheduledMessages
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
return replies
}
/**
* Get the number of scheduled messages for a given thread
*/
fun getScheduledMessageCount(threadId: Long): Observable<Int> {
return Observable.create { emitter ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) }
databaseObserver.registerScheduledMessageObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId))
}.subscribeOn(Schedulers.io())
}
fun rescheduleMessage(threadId: Long, messageId: Long, scheduleTime: Long) {
SignalDatabase.messages.rescheduleMessage(threadId, messageId, scheduleTime)
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
class ScheduledMessagesViewModel @JvmOverloads constructor(
private val threadId: Long,
private val repository: ScheduledMessagesRepository = ScheduledMessagesRepository()
) : ViewModel() {
fun getMessages(context: Context): Observable<List<ConversationMessage>> {
return repository.getScheduledMessages(context, threadId)
.observeOn(AndroidSchedulers.mainThread())
}
fun rescheduleMessage(messageId: Long, scheduleTime: Long) {
repository.rescheduleMessage(threadId, messageId, scheduleTime)
}
companion object {
private val TAG = Log.tag(ScheduledMessagesViewModel::class.java)
}
class Factory(private val threadId: Long) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T
}
}
}

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.WindowUtil;
import java.util.Collections;
import java.util.List;
@@ -81,6 +82,12 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
.subscribe(list::setDisplayOnlyMembers));
}
@Override
public void onResume() {
super.onResume();
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
}
@Override
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
BottomSheetUtil.show(manager, tag, this);

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation
import android.os.Build
import android.os.PowerManager
import androidx.activity.ComponentActivity
import androidx.lifecycle.DefaultLifecycleObserver
@@ -30,9 +29,7 @@ class VoiceRecorderWakeLock(
return
}
if (Build.VERSION.SDK_INT >= 21) {
wakeLock = WakeLockUtil.acquire(activity, PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TimeUnit.HOURS.toMillis(1), "voiceRecorder")
}
wakeLock = WakeLockUtil.acquire(activity, PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TimeUnit.HOURS.toMillis(1), "voiceRecorder")
}
}

Some files were not shown because too many files have changed in this diff Show More