mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-12 21:13:18 +01:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e84c6187b9 | ||
|
|
b5d52db57c | ||
|
|
f320cf8833 | ||
|
|
c76ca957e1 | ||
|
|
56354f6aae | ||
|
|
2bf84a5f77 | ||
|
|
8b23d9a6c4 | ||
|
|
2cae3ddf04 | ||
|
|
670b6c4c56 | ||
|
|
eceed641bf | ||
|
|
5febe6490c | ||
|
|
dca47e4cb5 | ||
|
|
c3bcba6380 | ||
|
|
cb01692a50 | ||
|
|
e90074ffef | ||
|
|
691520bc75 | ||
|
|
e6de06be6f | ||
|
|
a77079ac81 | ||
|
|
30b58fe5f4 | ||
|
|
7275b95b58 | ||
|
|
f04d46b4ed | ||
|
|
e12bbe943b | ||
|
|
7348224dc2 | ||
|
|
30c33fdd77 | ||
|
|
e7339af119 | ||
|
|
661fff7a0e | ||
|
|
c37bad0f7a | ||
|
|
7f228fc0fd | ||
|
|
14cd216668 | ||
|
|
0cb0ef977c | ||
|
|
1761529ce9 | ||
|
|
a14fc82e83 | ||
|
|
b94f5501d9 | ||
|
|
834283ba9b | ||
|
|
23190a2f6e | ||
|
|
04f4cd8edc | ||
|
|
8f02e4e1f5 | ||
|
|
db81a5be04 | ||
|
|
fe40e37da4 | ||
|
|
22a4271dfb | ||
|
|
1263b51e03 | ||
|
|
ca468047ef | ||
|
|
958c52a5b8 | ||
|
|
9b28585c59 | ||
|
|
c5c60b7214 | ||
|
|
31bcc2e2eb | ||
|
|
71ecba17fc | ||
|
|
afa5c68312 | ||
|
|
f3e715e069 | ||
|
|
df695f7611 | ||
|
|
27e1bc0854 | ||
|
|
f4371b9e96 | ||
|
|
e0633180ef | ||
|
|
32dd227ab6 | ||
|
|
0deed9d4d2 | ||
|
|
cc490f4b73 | ||
|
|
aa2075c78f | ||
|
|
b4a34599d7 | ||
|
|
8dd1d3bdeb | ||
|
|
a7d9bd944b | ||
|
|
7745ae62ea | ||
|
|
6e5b4bbc15 | ||
|
|
e3b38e6d38 | ||
|
|
25aa4f39a3 | ||
|
|
17849e20bd | ||
|
|
c022172ace | ||
|
|
eaeeb08987 | ||
|
|
1b7e4e047c | ||
|
|
d635683303 | ||
|
|
4dcbbfdd63 | ||
|
|
150bbf181d | ||
|
|
0303467c91 | ||
|
|
88da382a6f | ||
|
|
5d14166a27 | ||
|
|
d76d13f76c | ||
|
|
ad4ec23875 | ||
|
|
61df2afc32 | ||
|
|
1c6d2f7198 | ||
|
|
df8f9761b2 | ||
|
|
657c5d2bce | ||
|
|
81324c6923 | ||
|
|
269a2e2990 | ||
|
|
73b453b0d4 | ||
|
|
97604dc4c5 | ||
|
|
8e1c05ed64 | ||
|
|
231b55a956 | ||
|
|
4fcdee9fa5 | ||
|
|
6e2e5e21cc | ||
|
|
8f49323648 | ||
|
|
13f969b622 | ||
|
|
518d9b3984 | ||
|
|
8e313f8387 | ||
|
|
70c6e9e60f | ||
|
|
dcf8a82c37 | ||
|
|
f368e5b133 | ||
|
|
3ee889cb79 | ||
|
|
3e7dc79fe8 | ||
|
|
8cfd02aff2 | ||
|
|
f36efc562e | ||
|
|
67b6b109de | ||
|
|
8fd378db4e | ||
|
|
760ace93d4 | ||
|
|
125fd83afa | ||
|
|
7dcb598b66 | ||
|
|
4917e93d9f | ||
|
|
1e9115a917 | ||
|
|
7af94f60ae | ||
|
|
c3c8f8e7e6 | ||
|
|
011c85c75b |
2
.idea/codeStyles/Project.xml
generated
2
.idea/codeStyles/Project.xml
generated
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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_");
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<*>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -220,7 +220,8 @@ final class MenuState {
|
||||
messageRecord.isChangeNumber() ||
|
||||
messageRecord.isBoostRequest() ||
|
||||
messageRecord.isPaymentsRequestToActivate() ||
|
||||
messageRecord.isPaymentsActivated();
|
||||
messageRecord.isPaymentsActivated() ||
|
||||
messageRecord.isSmsExportType();
|
||||
}
|
||||
|
||||
private final static class Builder {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user