mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-14 14:03:18 +01:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e919f39f | ||
|
|
19080a8a5e | ||
|
|
61ce39b5b6 | ||
|
|
c64be82710 | ||
|
|
7bd34d2b99 | ||
|
|
4215b0391d | ||
|
|
96ea4c0cc2 | ||
|
|
1129ca28fb | ||
|
|
ba6e1b5dd5 | ||
|
|
ed25be2e23 | ||
|
|
7a0bd3315b | ||
|
|
8b806a8ac5 | ||
|
|
0ac5782f1f | ||
|
|
e10c20ffd7 | ||
|
|
86227fbd67 | ||
|
|
1cfa5c31f2 | ||
|
|
521bd2cce4 | ||
|
|
858c7a7f2e | ||
|
|
89a6730efe | ||
|
|
9bc25132c3 | ||
|
|
ebc556801e | ||
|
|
6b745ba58a | ||
|
|
6ddb5b983f | ||
|
|
8efd07b3e2 | ||
|
|
e85adad2b4 | ||
|
|
678a6f86ab | ||
|
|
9dc061e64f | ||
|
|
2fed3f7e90 | ||
|
|
af362736de | ||
|
|
d39a4b14e7 | ||
|
|
6a385c7a22 | ||
|
|
2c3d8337c3 | ||
|
|
28feba6a6c | ||
|
|
6ec7834046 | ||
|
|
ee4f3abf22 | ||
|
|
dc66583ef1 | ||
|
|
d30714bfd4 | ||
|
|
d04d2f7e93 | ||
|
|
1328aab939 | ||
|
|
2a9d2cf580 | ||
|
|
a316650aee | ||
|
|
4d1e8b8f75 | ||
|
|
9c7a5e3cc8 | ||
|
|
2022dae37a | ||
|
|
05b7055678 | ||
|
|
53c60e1f6d | ||
|
|
cd8fa58d7e |
7
.github/workflows/android.yml
vendored
7
.github/workflows/android.yml
vendored
@@ -14,11 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v1
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 11
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
@@ -32,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: reports
|
||||
path: '*/build/reports'
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build image
|
||||
run: cd reproducible-builds && docker build -t signal-android . && cd ..
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1076
|
||||
def canonicalVersionName = "5.41.11"
|
||||
def canonicalVersionCode = 1078
|
||||
def canonicalVersionName = "5.42.0"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
|
||||
@@ -80,25 +80,6 @@ class DistributionListDatabaseTest {
|
||||
Assert.assertEquals(StoryType.STORY_WITHOUT_REPLIES, storyType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExistsAndMarkedNoReplies_getAllListsForContactSelectionUi_returnsStoryWithoutReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
distributionDatabase.setAllowsReplies(id!!, false)
|
||||
|
||||
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
|
||||
Assert.assertFalse(records.first().allowsReplies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExists_getAllListsForContactSelectionUi_returnsStoryWithReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
|
||||
Assert.assertTrue(records.first().allowsReplies)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun givenStoryDoesNotExist_getStoryType_throwsIllegalStateException() {
|
||||
distributionDatabase.getStoryType(DistributionListId.from(12))
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.lang.AssertionError
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientDatabaseTest_processPnpTupleToChangeSet {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
private lateinit var db: RecipientDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
db = SignalDatabase.recipients
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164Only() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, null, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164AndPni() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_aciOnly() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun noMatch_pniOnly() {
|
||||
db.processPnpTupleToChangeSet(null, PNI_A, null, pniVerified = false)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun noMatch_noData() {
|
||||
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_allFields() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullMatch() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
Input(null, null, ACI_A)
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.thirdId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.thirdId,
|
||||
secondaryId = result.firstId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.SetAci(
|
||||
recipientId = result.firstId,
|
||||
aci = ACI_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniAndE164_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.firstId,
|
||||
pni = PNI_A
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.secondId),
|
||||
PnpOperation.SessionSwitchoverInsert(result.firstId)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(null, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_B, PNI_A, null),
|
||||
Input(null, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.Update(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
pni = PNI_A,
|
||||
aci = ACI_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, PNI_B, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164Aci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||
RecipientDatabase.TABLE_NAME,
|
||||
null,
|
||||
contentValuesOf(
|
||||
RecipientDatabase.PHONE to e164,
|
||||
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
|
||||
RecipientDatabase.PNI_COLUMN to pni?.toString(),
|
||||
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
|
||||
)
|
||||
)
|
||||
|
||||
return RecipientId.from(id)
|
||||
}
|
||||
|
||||
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
|
||||
SignalDatabase.rawDatabase.insert(
|
||||
SessionDatabase.TABLE_NAME, null,
|
||||
contentValuesOf(
|
||||
SessionDatabase.ACCOUNT_ID to account.toString(),
|
||||
SessionDatabase.ADDRESS to address.toString(),
|
||||
SessionDatabase.DEVICE to 1,
|
||||
SessionDatabase.RECORD to Util.getSecretBytes(32)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
|
||||
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
|
||||
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
|
||||
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
|
||||
val id
|
||||
get() = if (ids.size == 1) {
|
||||
ids[0]
|
||||
} else {
|
||||
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
|
||||
}
|
||||
|
||||
val firstId
|
||||
get() = ids[0]
|
||||
|
||||
val secondId
|
||||
get() = ids[1]
|
||||
|
||||
val thirdId
|
||||
get() = ids[2]
|
||||
}
|
||||
|
||||
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
|
||||
return applyAndAssert(listOf(input), update, output)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method that will call insert your recipients, call [RecipientDatabase.processPnpTupleToChangeSet] with your params,
|
||||
* and then verify your output matches what you expect.
|
||||
*
|
||||
* It results the inserted ID's and changeset for additional verification.
|
||||
*
|
||||
* But basically this is here to make the tests more readable. It gives you a clear list of:
|
||||
* - input
|
||||
* - update
|
||||
* - output
|
||||
*
|
||||
* that you can spot check easily.
|
||||
*
|
||||
* Important: The output will only include records that contain fields from the input. That means
|
||||
* for:
|
||||
*
|
||||
* Input: E164_B, PNI_A, null
|
||||
* Update: E164_A, PNI_A, null
|
||||
*
|
||||
* You will get:
|
||||
* Output: E164_A, PNI_A, null
|
||||
*
|
||||
* Even though there was an update that will also result in the row (E164_B, null, null)
|
||||
*/
|
||||
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
|
||||
val ids = input.map { insert(it.e164, it.pni, it.aci) }
|
||||
|
||||
input
|
||||
.filter { it.pniSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
|
||||
|
||||
input
|
||||
.filter { it.aciSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
|
||||
|
||||
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
|
||||
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
|
||||
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
|
||||
|
||||
val data = PnpDataSet(
|
||||
e164 = update.e164,
|
||||
pni = update.pni,
|
||||
aci = update.aci,
|
||||
byE164 = byE164,
|
||||
byPniSid = byPniSid,
|
||||
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
|
||||
byAciSid = byAciSid,
|
||||
e164Record = byE164?.let { db.getRecord(it) },
|
||||
pniSidRecord = byPniSid?.let { db.getRecord(it) },
|
||||
aciSidRecord = byAciSid?.let { db.getRecord(it) }
|
||||
)
|
||||
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
|
||||
|
||||
val finalData = data.perform(changeSet.operations)
|
||||
|
||||
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
|
||||
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
|
||||
|
||||
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
|
||||
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
|
||||
|
||||
return PnpMatchResult(
|
||||
ids = ids,
|
||||
changeSet = changeSet
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
|
||||
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
|
||||
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
|
||||
|
||||
const val E164_A = "+12221234567"
|
||||
const val E164_B = "+13331234567"
|
||||
}
|
||||
}
|
||||
@@ -122,4 +122,62 @@ class SQLiteDatabaseTest {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
hasRun.set(true)
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransactionAndRunPostNested() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun2.set(true)
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
hasRun1.set(true)
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
class ThreadDatabaseTest_pinned {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIDoNotDeleteOrUnpinTheThread() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.mms.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val pinned = SignalDatabase.threads.pinnedThreadIds
|
||||
assertTrue(threadId in pinned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIExpectTheThreadInUnarchivedCount() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.mms.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val unarchivedCount = SignalDatabase.threads.unarchivedConversationListCount
|
||||
assertEquals(1, unarchivedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIExpectPinnedThreadInUnarchivedList() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.mms.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
SignalDatabase.threads.getUnarchivedConversationList(true, 0, 1).use {
|
||||
it.moveToFirst()
|
||||
assertEquals(threadId, CursorUtil.requireLong(it, ThreadDatabase.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Sets up bare-minimum to allow writing unit tests against the database,
|
||||
* including setting up the local ACI and PNI pair.
|
||||
*
|
||||
* @param deleteAllThreadsOnEachRun Run deleteAllThreads between each unit test
|
||||
*/
|
||||
class SignalDatabaseRule(
|
||||
private val deleteAllThreadsOnEachRun: Boolean = true
|
||||
) : TestWatcher() {
|
||||
|
||||
val localAci: ACI = ACI.from(UUID.randomUUID())
|
||||
val localPni: PNI = PNI.from(UUID.randomUUID())
|
||||
|
||||
override fun starting(description: Description?) {
|
||||
deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
}
|
||||
|
||||
override fun finished(description: Description?) {
|
||||
deleteAllThreads()
|
||||
}
|
||||
|
||||
private fun deleteAllThreads() {
|
||||
if (deleteAllThreadsOnEachRun) {
|
||||
SignalDatabase.mms.deleteAllThreads()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean canPlayInline,
|
||||
@NonNull Colorizer colorizer);
|
||||
@NonNull Colorizer colorizer,
|
||||
boolean isCondensedMode);
|
||||
|
||||
@NonNull ConversationMessage getConversationMessage();
|
||||
|
||||
@@ -61,12 +62,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
}
|
||||
|
||||
default void updateSelectedState() {
|
||||
// Intentionall Blank.
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
void onQuoteClicked(MmsMessageRecord messageRecord);
|
||||
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
||||
void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms);
|
||||
void onStickerClicked(@NonNull StickerLocator stickerLocator);
|
||||
void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
|
||||
|
||||
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.LayoutTransition;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -32,7 +31,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -43,6 +41,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
@@ -54,7 +53,6 @@ import androidx.transition.TransitionManager;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
@@ -62,7 +60,7 @@ import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChip;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
@@ -70,21 +68,25 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -94,6 +96,9 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
*
|
||||
@@ -105,7 +110,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
|
||||
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
|
||||
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 0;
|
||||
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
@@ -133,10 +138,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private ChipGroup chipGroup;
|
||||
private HorizontalScrollView chipGroupScrollContainer;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
@@ -248,8 +255,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipGroup = view.findViewById(R.id.chipGroup);
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
@@ -263,6 +269,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
});
|
||||
|
||||
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
|
||||
contactChipAdapter = new MappingAdapter();
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
SelectedContacts.register(contactChipAdapter, this::onChipCloseIconClicked);
|
||||
chipRecycler.setAdapter(contactChipAdapter);
|
||||
|
||||
Disposable disposable = contactChipViewModel.getState().subscribe(this::handleSelectedContactsChanged);
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Bundle arguments = safeArguments();
|
||||
|
||||
@@ -385,7 +403,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
null,
|
||||
new ListClickListener(),
|
||||
isMulti,
|
||||
currentSelection);
|
||||
currentSelection,
|
||||
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
|
||||
|
||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||
|
||||
@@ -728,75 +747,22 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
removeChipForContact(selectedContact);
|
||||
contactChipViewModel.remove(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
|
||||
View v = chipGroup.getChildAt(i);
|
||||
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
|
||||
chipGroup.removeView(v);
|
||||
}
|
||||
}
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
|
||||
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
if (selectedContacts.isEmpty()) {
|
||||
setChipGroupVisibility(ConstraintSet.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> addChipForRecipient(resolved, selectedContact));
|
||||
}
|
||||
|
||||
private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
|
||||
final ContactChip chip = new ContactChip(requireContext());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
} else {
|
||||
setChipGroupVisibility(ConstraintSet.VISIBLE);
|
||||
}
|
||||
|
||||
chip.setText(recipient.getShortDisplayName(requireContext()));
|
||||
chip.setContact(selectedContact);
|
||||
chip.setCloseIconVisible(true);
|
||||
chip.setOnCloseIconClickListener(view -> {
|
||||
markContactUnselected(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orElse(null));
|
||||
}
|
||||
});
|
||||
|
||||
chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
|
||||
@Override
|
||||
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
if (getView() == null || !requireView().isAttachedToWindow()) {
|
||||
Log.w(TAG, "Fragment's view was detached before the animation completed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (view == chip && transitionType == LayoutTransition.APPEARING) {
|
||||
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
||||
registerChipRecipientObserver(chip, recipient.live());
|
||||
chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
|
||||
}
|
||||
|
||||
private void addChip(@NonNull ContactChip chip) {
|
||||
chipGroup.addView(chip);
|
||||
if (selectionWarningLimitReachedExactly()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||
@@ -806,21 +772,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
private int getChipCount() {
|
||||
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
||||
if (count < 0) throw new AssertionError();
|
||||
return count;
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
}
|
||||
|
||||
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
|
||||
if (recipient != null) {
|
||||
recipient.observe(getViewLifecycleOwner(), resolved -> {
|
||||
if (chip.isAttachedToWindow()) {
|
||||
chip.setAvatar(glideRequests, resolved, null);
|
||||
chip.setText(resolved.getShortDisplayName(chip.getContext()));
|
||||
}
|
||||
});
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
|
||||
markContactUnselected(model.getSelectedContact());
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
|
||||
}
|
||||
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
private int getChipCount() {
|
||||
int count = contactChipViewModel.getCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
||||
if (count < 0) throw new AssertionError();
|
||||
return count;
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
@@ -836,7 +806,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(constraintLayout);
|
||||
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
|
||||
constraintSet.setVisibility(R.id.chipRecycler, visibility);
|
||||
constraintSet.applyTo(constraintLayout);
|
||||
}
|
||||
|
||||
@@ -845,8 +815,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void smoothScrollChipsToEnd() {
|
||||
int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0;
|
||||
chipGroupScrollContainer.smoothScrollTo(x, 0);
|
||||
int x = ViewUtil.isLtr(chipRecycler) ? chipRecycler.getWidth() : 0;
|
||||
chipRecycler.smoothScrollBy(x, 0);
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
|
||||
@@ -87,6 +87,8 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
setLayoutDirection(LAYOUT_DIRECTION_LTR);
|
||||
|
||||
TypedArray typedArray = null;
|
||||
try {
|
||||
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.widget.ImageView;
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -35,6 +36,7 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
private int[] normalBounds;
|
||||
private int[] gifBounds;
|
||||
private int minimumThumbnailWidth;
|
||||
private int maximumThumbnailHeight;
|
||||
|
||||
public ConversationItemThumbnail(Context context) {
|
||||
super(context);
|
||||
@@ -83,7 +85,8 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
Integer.MAX_VALUE
|
||||
};
|
||||
|
||||
minimumThumbnailWidth = -1;
|
||||
minimumThumbnailWidth = -1;
|
||||
maximumThumbnailHeight = -1;
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousNameCombination")
|
||||
@@ -143,11 +146,16 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
|
||||
}
|
||||
|
||||
public void setMinimumThumbnailWidth(int width) {
|
||||
public void setMinimumThumbnailWidth(@Px int width) {
|
||||
minimumThumbnailWidth = width;
|
||||
thumbnail.setMinimumThumbnailWidth(width);
|
||||
}
|
||||
|
||||
public void setMaximumThumbnailHeight(@Px int height) {
|
||||
maximumThumbnailHeight = height;
|
||||
thumbnail.setMaximumThumbnailHeight(height);
|
||||
}
|
||||
|
||||
public void setBorderless(boolean borderless) {
|
||||
this.borderless = borderless;
|
||||
}
|
||||
@@ -170,6 +178,10 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
if (minimumThumbnailWidth != -1) {
|
||||
thumbnail.setMinimumThumbnailWidth(minimumThumbnailWidth);
|
||||
}
|
||||
|
||||
if (maximumThumbnailHeight != -1) {
|
||||
thumbnail.setMaximumThumbnailHeight(maximumThumbnailHeight);
|
||||
}
|
||||
}
|
||||
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
@@ -31,6 +32,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
@ColorInt
|
||||
protected var backgroundColor: Int = Color.TRANSPARENT
|
||||
|
||||
private lateinit var dialogBackground: MaterialShapeDrawable
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, themeResId)
|
||||
@@ -46,11 +49,11 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
|
||||
.build()
|
||||
|
||||
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
|
||||
val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), R.attr.bottomSheetStyle)
|
||||
backgroundColor = ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint)
|
||||
dialogBackground.setTint(backgroundColor)
|
||||
dialogBackground.fillColor = ColorStateList.valueOf(backgroundColor)
|
||||
|
||||
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
|
||||
@@ -22,8 +22,10 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
|
||||
protected open val withDim: Boolean = false
|
||||
|
||||
protected open val themeResId: Int = R.style.Theme_Signal_RoundedBottomSheet
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
|
||||
setStyle(STYLE_NORMAL, themeResId)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ public class LinkPreviewView extends FrameLayout {
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
||||
setLinkPreview(glideRequests, linkPreview, showThumbnail, true);
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showDescription) {
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(GONE);
|
||||
|
||||
@@ -138,7 +142,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
title.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (!Util.isEmpty(linkPreview.getDescription())) {
|
||||
if (showDescription && !Util.isEmpty(linkPreview.getDescription())) {
|
||||
description.setText(linkPreview.getDescription());
|
||||
description.setVisibility(VISIBLE);
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
@@ -155,11 +156,16 @@ public class ThumbnailView extends FrameLayout {
|
||||
captionIcon.setScaleY(captionIconScale);
|
||||
}
|
||||
|
||||
public void setMinimumThumbnailWidth(int width) {
|
||||
public void setMinimumThumbnailWidth(@Px int width) {
|
||||
bounds[MIN_WIDTH] = width;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setMaximumThumbnailHeight(@Px int height) {
|
||||
bounds[MAX_HEIGHT] = height;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousNameCombination")
|
||||
private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) {
|
||||
int dimensFilledCount = getNonZeroCount(dimens);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
/**
|
||||
* Convenience class for wrapping Fragments in full-screen dialogs. Due to how fragments work, they
|
||||
* must be public static classes. Therefore, this class should be subclassed as its own entity, rather
|
||||
* than via `object : WrapperDialogFragment`.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* class Dialog : WrapperDialogFragment() {
|
||||
* override fun getWrappedFragment(): Fragment {
|
||||
* return NavHostFragment.create(R.navigation.private_story_settings, requireArguments())
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* companion object {
|
||||
* fun createAsDialog(distributionListId: DistributionListId): DialogFragment {
|
||||
* return Dialog().apply {
|
||||
* arguments = PrivateStorySettingsFragmentArgs.Builder(distributionListId).build().toBundle()
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_container) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
if (savedInstanceState == null) {
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_container, getWrappedFragment())
|
||||
.commitAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
findListener<WrapperDialogFragmentCallback>()?.onWrapperDialogFragmentDismissed()
|
||||
}
|
||||
|
||||
abstract fun getWrappedFragment(): Fragment
|
||||
|
||||
interface WrapperDialogFragmentCallback {
|
||||
fun onWrapperDialogFragmentDismissed()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
@@ -151,10 +152,10 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
|
||||
if (maxLength > 0) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
} else if (getMaxLines() > 0) {
|
||||
if (getMaxLines() > 0) {
|
||||
ellipsizeEmojiTextForMaxLines();
|
||||
} else if (maxLength > 0) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,11 +309,17 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
int lineCount = getLineCount();
|
||||
if (lineCount > maxLines) {
|
||||
int overflowStart = getLayout().getLineStart(maxLines - 1);
|
||||
int overflowStart = getLayout().getLineStart(maxLines - 1);
|
||||
|
||||
if (maxLength > 0 && overflowStart > maxLength) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
return;
|
||||
}
|
||||
|
||||
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
|
||||
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
|
||||
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, overflowStart))
|
||||
@@ -323,6 +330,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
} else if (maxLength > 0) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents an action to be rendered via [SignalContextMenu] or [SignalBottomActionBar]
|
||||
*/
|
||||
data class ActionItem(
|
||||
data class ActionItem @JvmOverloads constructor(
|
||||
@DrawableRes val iconRes: Int,
|
||||
val title: CharSequence,
|
||||
val action: Runnable
|
||||
@ColorRes val tintRes: Int = R.color.signal_colorOnSurface,
|
||||
val action: Runnable,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -78,6 +79,10 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
val tintColor = ContextCompat.getColor(context, model.item.tintRes)
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class DSLSettingsAdapter : MappingAdapter() {
|
||||
@@ -29,6 +30,7 @@ class DSLSettingsAdapter : MappingAdapter() {
|
||||
registerFactory(ClickPreference::class.java, LayoutFactory(::ClickPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
registerFactory(LongClickPreference::class.java, LayoutFactory(::LongClickPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
registerFactory(TextPreference::class.java, LayoutFactory(::TextPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
registerFactory(LearnMoreTextPreference::class.java, LayoutFactory(::LearnMoreTextPreferenceViewHolder, R.layout.dsl_learn_more_preference_item))
|
||||
registerFactory(RadioListPreference::class.java, LayoutFactory(::RadioListPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
registerFactory(MultiSelectListPreference::class.java, LayoutFactory(::MultiSelectListPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
registerFactory(ExternalLinkPreference::class.java, LayoutFactory(::ExternalLinkPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
@@ -91,6 +93,14 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
|
||||
|
||||
class TextPreferenceViewHolder(itemView: View) : PreferenceViewHolder<TextPreference>(itemView)
|
||||
|
||||
class LearnMoreTextPreferenceViewHolder(itemView: View) : PreferenceViewHolder<LearnMoreTextPreference>(itemView) {
|
||||
override fun bind(model: LearnMoreTextPreference) {
|
||||
super.bind(model)
|
||||
(titleView as LearnMoreTextView).setOnLinkClickListener { model.onClick() }
|
||||
(summaryView as LearnMoreTextView).setOnLinkClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
|
||||
class ClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder<ClickPreference>(itemView) {
|
||||
override fun bind(model: ClickPreference) {
|
||||
super.bind(model)
|
||||
|
||||
@@ -43,9 +43,9 @@ class InternalSettingsRepository(context: Context) {
|
||||
body = body,
|
||||
threadId = threadId,
|
||||
messageRanges = bodyRangeList.build(),
|
||||
image = "https://via.placeholder.com/720x480",
|
||||
imageWidth = 720,
|
||||
imageHeight = 480
|
||||
image = "/static/release-notes/signal.png",
|
||||
imageWidth = 1800,
|
||||
imageHeight = 720
|
||||
)
|
||||
|
||||
SignalDatabase.sms.insertBoostRequestMessage(recipientId, threadId)
|
||||
|
||||
@@ -235,7 +235,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState {
|
||||
toolbarTitle.text = state.recipient.getDisplayName(requireContext())
|
||||
toolbarTitle.text = if (state.recipient.isSelf) getString(R.string.note_to_self) else state.recipient.getDisplayName(requireContext())
|
||||
}
|
||||
|
||||
state.withGroupSettingsState {
|
||||
|
||||
@@ -185,6 +185,15 @@ class DSLConfiguration {
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun learnMoreTextPref(
|
||||
title: DSLSettingsText? = null,
|
||||
summary: DSLSettingsText? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = LearnMoreTextPreference(title, summary, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun toMappingModelList(): MappingModelList = MappingModelList().apply { addAll(children) }
|
||||
}
|
||||
|
||||
@@ -218,6 +227,12 @@ class TextPreference(
|
||||
summary: DSLSettingsText?
|
||||
) : PreferenceModel<TextPreference>(title = title, summary = summary)
|
||||
|
||||
class LearnMoreTextPreference(
|
||||
override val title: DSLSettingsText?,
|
||||
override val summary: DSLSettingsText?,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<LearnMoreTextPreference>()
|
||||
|
||||
class DividerPreference : PreferenceModel<DividerPreference>() {
|
||||
override fun areItemsTheSame(newItem: DividerPreference) = true
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ class VoiceNoteMediaItemFactory {
|
||||
sender.getDisplayName(context),
|
||||
threadRecipient.getDisplayName(context));
|
||||
} else if (preference.isDisplayContact()) {
|
||||
return sender.getDisplayName(context);
|
||||
return sender.isSelf() ? context.getString(R.string.note_to_self)
|
||||
: sender.getDisplayName(context);
|
||||
} else {
|
||||
return context.getString(R.string.MessageNotifier_signal_message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
/**
|
||||
* ViewModel expressly for displaying the current state of the contact chips
|
||||
* in the contact selection fragment.
|
||||
*/
|
||||
class ContactChipViewModel : ViewModel() {
|
||||
|
||||
private val store = RxStore(emptyList<SelectedContacts.Model>())
|
||||
|
||||
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
val count = store.state.size
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val disposableMap: MutableMap<RecipientId, Disposable> = mutableMapOf()
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
disposableMap.values.forEach { it.dispose() }
|
||||
disposableMap.clear()
|
||||
}
|
||||
|
||||
fun add(selectedContact: SelectedContact) {
|
||||
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
|
||||
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
|
||||
disposableMap[recipient.id]?.dispose()
|
||||
disposableMap[recipient.id] = store.update(recipient.live().asObservable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
|
||||
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
|
||||
when {
|
||||
index == 0 -> {
|
||||
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
|
||||
}
|
||||
index > 0 -> {
|
||||
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
|
||||
}
|
||||
else -> {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(selectedContact: SelectedContact) {
|
||||
store.update { list ->
|
||||
list.filterNot { it.selectedContact.matches(selectedContact) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateRecipientId(selectedContact: SelectedContact): Single<RecipientId> {
|
||||
return Single.fromCallable {
|
||||
selectedContact.getOrCreateRecipientId(ApplicationDependencies.getApplication())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
private final ItemClickListener clickListener;
|
||||
private final GlideRequests glideRequests;
|
||||
private final Set<RecipientId> currentContacts;
|
||||
private final int checkboxResource;
|
||||
|
||||
private final SelectedContactSet selectedContacts = new SelectedContactSet();
|
||||
|
||||
@@ -205,14 +206,16 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
@Nullable Cursor cursor,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
boolean multiSelect,
|
||||
@NonNull Set<RecipientId> currentContacts)
|
||||
@NonNull Set<RecipientId> currentContacts,
|
||||
int checkboxResource)
|
||||
{
|
||||
super(context, cursor);
|
||||
this.layoutInflater = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
this.currentContacts = currentContacts;
|
||||
this.layoutInflater = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
this.currentContacts = currentContacts;
|
||||
this.checkboxResource = checkboxResource;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -229,7 +232,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
@Override
|
||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
||||
if (viewType == VIEW_TYPE_CONTACT) {
|
||||
return new ContactViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
|
||||
View view = layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false);
|
||||
view.findViewById(R.id.check_box).setBackgroundResource(checkboxResource);
|
||||
return new ContactViewHolder(view, clickListener);
|
||||
} else {
|
||||
return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
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.MappingViewHolder
|
||||
|
||||
object SelectedContacts {
|
||||
@JvmStatic
|
||||
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
}
|
||||
|
||||
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
chip.text = model.recipient.getShortDisplayName(context)
|
||||
chip.setContact(model.selectedContact)
|
||||
chip.isCloseIconVisible = true
|
||||
chip.setOnCloseIconClickListener {
|
||||
onCloseIconClicked(model)
|
||||
}
|
||||
chip.setAvatar(GlideApp.with(itemView), model.recipient, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.FromTextView
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -15,6 +18,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
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
|
||||
*/
|
||||
@@ -22,13 +28,14 @@ object ContactSearchItems {
|
||||
fun register(
|
||||
mappingAdapter: MappingAdapter,
|
||||
displayCheckBox: Boolean,
|
||||
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
|
||||
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
|
||||
recipientListener: RecipientClickListener,
|
||||
storyListener: StoryClickListener,
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks,
|
||||
expandListener: (ContactSearchData.Expand) -> Unit
|
||||
) {
|
||||
mappingAdapter.registerFactory(
|
||||
StoryModel::class.java,
|
||||
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener) }, R.layout.contact_search_item)
|
||||
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
RecipientModel::class.java,
|
||||
@@ -79,7 +86,7 @@ object ContactSearchItems {
|
||||
}
|
||||
}
|
||||
|
||||
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
|
||||
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: StoryClickListener, private val storyContextMenuCallbacks: StoryContextMenuCallbacks) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
|
||||
override fun isSelected(model: StoryModel): Boolean = model.isSelected
|
||||
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
||||
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
|
||||
@@ -101,6 +108,50 @@ object ContactSearchItems {
|
||||
|
||||
number.text = context.resources.getQuantityString(pluralId, count, count)
|
||||
}
|
||||
|
||||
override fun bindLongPress(model: StoryModel) {
|
||||
itemView.setOnLongClickListener {
|
||||
val actions: List<ActionItem> = when {
|
||||
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model)
|
||||
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model)
|
||||
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model)
|
||||
else -> error("Unsupported story target. Not a group or distribution list.")
|
||||
}
|
||||
|
||||
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
|
||||
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
|
||||
.show(actions)
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMyStoryContextMenuActions(model: StoryModel): List<ActionItem> {
|
||||
return listOf(
|
||||
ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) {
|
||||
storyContextMenuCallbacks.onOpenStorySettings(model.story)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getGroupStoryContextMenuActions(model: StoryModel): List<ActionItem> {
|
||||
return listOf(
|
||||
ActionItem(R.drawable.ic_minus_circle_20, context.getString(R.string.ContactSearchItems__remove_story)) {
|
||||
storyContextMenuCallbacks.onRemoveGroupStory(model.story, model.isSelected)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPrivateStoryContextMenuActions(model: StoryModel): List<ActionItem> {
|
||||
return listOf(
|
||||
ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) {
|
||||
storyContextMenuCallbacks.onOpenStorySettings(model.story)
|
||||
},
|
||||
ActionItem(R.drawable.ic_delete_24, context.getString(R.string.ContactSearchItems__delete_story), R.color.signal_colorError) {
|
||||
storyContextMenuCallbacks.onDeletePrivateStory(model.story, model.isSelected)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +176,7 @@ object ContactSearchItems {
|
||||
}
|
||||
}
|
||||
|
||||
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
|
||||
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: RecipientClickListener) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
|
||||
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
|
||||
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
|
||||
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
|
||||
@@ -134,7 +185,7 @@ object ContactSearchItems {
|
||||
/**
|
||||
* Base Recipient View Holder
|
||||
*/
|
||||
private abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
private abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (View, D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
|
||||
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
|
||||
@@ -147,7 +198,8 @@ object ContactSearchItems {
|
||||
override fun bind(model: T) {
|
||||
checkbox.visible = displayCheckBox
|
||||
checkbox.isChecked = isSelected(model)
|
||||
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
|
||||
itemView.setOnClickListener { onClick(itemView, getData(model), isSelected(model)) }
|
||||
bindLongPress(model)
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
@@ -185,6 +237,8 @@ object ContactSearchItems {
|
||||
smsTag.visible = isSmsContact(model)
|
||||
}
|
||||
|
||||
protected open fun bindLongPress(model: T) = Unit
|
||||
|
||||
private fun isSmsContact(model: T): Boolean {
|
||||
return (getRecipient(model).isForceSmsSelection || getRecipient(model).isUnregistered) && !getRecipient(model).isDistributionList
|
||||
}
|
||||
@@ -268,4 +322,10 @@ object ContactSearchItems {
|
||||
return if (isLeftSelf == isRightSelf) 0 else if (isLeftSelf) 1 else -1
|
||||
}
|
||||
}
|
||||
|
||||
interface StoryContextMenuCallbacks {
|
||||
fun onOpenStorySettings(story: ContactSearchData.Story)
|
||||
fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean)
|
||||
fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.view.View
|
||||
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 org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
|
||||
class ContactSearchMediator(
|
||||
fragment: Fragment,
|
||||
private val fragment: Fragment,
|
||||
recyclerView: RecyclerView,
|
||||
selectionLimits: SelectionLimits,
|
||||
displayCheckBox: Boolean,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
|
||||
private val contactSelectionPreFilter: (View?, Set<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s }
|
||||
) {
|
||||
|
||||
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java)
|
||||
|
||||
init {
|
||||
|
||||
val adapter = PagingMappingAdapter<ContactSearchKey>()
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
@@ -27,6 +36,7 @@ class ContactSearchMediator(
|
||||
displayCheckBox = displayCheckBox,
|
||||
recipientListener = this::toggleSelection,
|
||||
storyListener = this::toggleSelection,
|
||||
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
|
||||
expandListener = { viewModel.expandSection(it.sectionKey) }
|
||||
)
|
||||
|
||||
@@ -54,7 +64,7 @@ class ContactSearchMediator(
|
||||
}
|
||||
|
||||
fun setKeysSelected(keys: Set<ContactSearchKey>) {
|
||||
viewModel.setKeysSelected(keys)
|
||||
viewModel.setKeysSelected(contactSelectionPreFilter(null, keys))
|
||||
}
|
||||
|
||||
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
|
||||
@@ -73,11 +83,45 @@ class ContactSearchMediator(
|
||||
viewModel.addToVisibleGroupStories(groupStories)
|
||||
}
|
||||
|
||||
private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) {
|
||||
if (isSelected) {
|
||||
fun refresh() {
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
|
||||
return if (isSelected) {
|
||||
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
|
||||
} else {
|
||||
viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey))
|
||||
viewModel.setKeysSelected(contactSelectionPreFilter(view, setOf(contactSearchData.contactSearchKey)))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StoryContextMenuCallbacks : ContactSearchItems.StoryContextMenuCallbacks {
|
||||
override fun onOpenStorySettings(story: ContactSearchData.Story) {
|
||||
if (story.recipient.isMyStory) {
|
||||
MyStorySettingsFragment.createAsDialog()
|
||||
.show(fragment.childFragmentManager, null)
|
||||
} else {
|
||||
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId())
|
||||
.show(fragment.childFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
|
||||
MaterialAlertDialogBuilder(fragment.requireContext())
|
||||
.setTitle(R.string.ContactSearchMediator__remove_group_story)
|
||||
.setMessage(R.string.ContactSearchMediator__this_will_remove)
|
||||
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
|
||||
MaterialAlertDialogBuilder(fragment.requireContext())
|
||||
.setTitle(R.string.ContactSearchMediator__delete_story)
|
||||
.setMessage(fragment.getString(R.string.ContactSearchMediator__delete_the_private, story.recipient.getDisplayName(fragment.requireContext())))
|
||||
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(fragment.requireContext(), R.color.signal_colorError), fragment.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
|
||||
class ContactSearchRepository {
|
||||
fun filterOutUnselectableContactSearchKeys(contactSearchKeys: Set<ContactSearchKey>): Single<Set<ContactSearchSelectionResult>> {
|
||||
@@ -29,4 +34,17 @@ class ContactSearchRepository {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun unmarkDisplayAsStory(groupId: GroupId): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.groups.markDisplayAsStory(groupId, false)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deletePrivateStory(distributionListId: DistributionListId): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.distributionLists.deleteList(distributionListId)
|
||||
Stories.onStorySettingsChanged(distributionListId)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.signal.paging.PagingController
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
/**
|
||||
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
|
||||
@@ -97,6 +98,31 @@ class ContactSearchViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeGroupStory(story: ContactSearchData.Story) {
|
||||
Preconditions.checkArgument(story.recipient.isGroup)
|
||||
setKeysNotSelected(setOf(story.contactSearchKey))
|
||||
disposables += contactSearchRepository.unmarkDisplayAsStory(story.recipient.requireGroupId()).subscribe {
|
||||
configurationStore.update { state ->
|
||||
state.copy(
|
||||
groupStories = state.groupStories.filter { it.recipient.id == story.recipient.id }.toSet()
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePrivateStory(story: ContactSearchData.Story) {
|
||||
Preconditions.checkArgument(story.recipient.isDistributionList && !story.recipient.isMyStory)
|
||||
setKeysNotSelected(setOf(story.contactSearchKey))
|
||||
disposables += contactSearchRepository.deletePrivateStory(story.recipient.requireDistributionListId()).subscribe {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
controller.value?.onDataInvalidated()
|
||||
}
|
||||
|
||||
class Factory(private val selectionLimits: SelectionLimits, private val repository: ContactSearchRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository)) as T
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.contacts.selection
|
||||
|
||||
import android.os.Bundle
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -15,7 +16,8 @@ data class ContactSelectionArguments(
|
||||
val canSelectSelf: Boolean = selectionLimits == null,
|
||||
val displayChips: Boolean = true,
|
||||
val recyclerPadBottom: Int = -1,
|
||||
val recyclerChildClipping: Boolean = true
|
||||
val recyclerChildClipping: Boolean = true,
|
||||
val checkboxResource: Int = R.drawable.contact_selection_checkbox
|
||||
) {
|
||||
|
||||
fun toArgumentBundle(): Bundle {
|
||||
@@ -29,6 +31,7 @@ data class ContactSelectionArguments(
|
||||
putBoolean(DISPLAY_CHIPS, displayChips)
|
||||
putInt(RV_PADDING_BOTTOM, recyclerPadBottom)
|
||||
putBoolean(RV_CLIP, recyclerChildClipping)
|
||||
putInt(CHECKBOX_RESOURCE, checkboxResource)
|
||||
putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection))
|
||||
}
|
||||
}
|
||||
@@ -44,5 +47,6 @@ data class ContactSelectionArguments(
|
||||
const val DISPLAY_CHIPS = "display_chips"
|
||||
const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom"
|
||||
const val RV_CLIP = "recycler_view_clipping"
|
||||
const val CHECKBOX_RESOURCE = "checkbox_resource"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,24 @@ package org.thoughtcrime.securesms.contactshare;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -31,8 +34,9 @@ import static org.thoughtcrime.securesms.contactshare.ContactShareEditViewModel.
|
||||
|
||||
public class ContactShareEditActivity extends PassphraseRequiredActivity implements ContactShareEditAdapter.EventListener {
|
||||
|
||||
public static final String KEY_CONTACTS = "contacts";
|
||||
private static final String KEY_CONTACT_URIS = "contact_uris";
|
||||
public static final String KEY_CONTACTS = "contacts";
|
||||
private static final String KEY_CONTACT_URIS = "contact_uris";
|
||||
private static final String KEY_SEND_BUTTON_COLOR = "send_button_color";
|
||||
private static final int CODE_NAME_EDIT = 55;
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicTheme();
|
||||
@@ -40,11 +44,12 @@ public class ContactShareEditActivity extends PassphraseRequiredActivity impleme
|
||||
|
||||
private ContactShareEditViewModel viewModel;
|
||||
|
||||
public static Intent getIntent(@NonNull Context context, @NonNull List<Uri> contactUris) {
|
||||
public static Intent getIntent(@NonNull Context context, @NonNull List<Uri> contactUris, @ColorInt int sendButtonColor) {
|
||||
ArrayList<Uri> contactUriList = new ArrayList<>(contactUris);
|
||||
|
||||
Intent intent = new Intent(context, ContactShareEditActivity.class);
|
||||
intent.putParcelableArrayListExtra(KEY_CONTACT_URIS, contactUriList);
|
||||
intent.putExtra(KEY_SEND_BUTTON_COLOR, sendButtonColor);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@@ -68,6 +73,7 @@ public class ContactShareEditActivity extends PassphraseRequiredActivity impleme
|
||||
}
|
||||
|
||||
View sendButton = findViewById(R.id.contact_share_edit_send);
|
||||
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(getIntent().getIntExtra(KEY_SEND_BUTTON_COLOR, Color.RED)));
|
||||
sendButton.setOnClickListener(v -> onSendClicked(viewModel.getFinalizedContacts()));
|
||||
|
||||
RecyclerView contactList = findViewById(R.id.contact_share_edit_list);
|
||||
|
||||
@@ -123,8 +123,9 @@ public class ConversationAdapter
|
||||
private ConversationMessage inlineContent;
|
||||
private Colorizer colorizer;
|
||||
private boolean isTypingViewEnabled;
|
||||
private boolean condensedMode;
|
||||
|
||||
ConversationAdapter(@NonNull Context context,
|
||||
public ConversationAdapter(@NonNull Context context,
|
||||
@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@@ -177,9 +178,9 @@ public class ConversationAdapter
|
||||
} else if (messageRecord.isUpdate()) {
|
||||
return MESSAGE_TYPE_UPDATE;
|
||||
} else if (messageRecord.isOutgoing()) {
|
||||
return MessageRecordUtil.isTextOnly(messageRecord, context) ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA;
|
||||
return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA;
|
||||
} else {
|
||||
return MessageRecordUtil.isTextOnly(messageRecord, context) ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA;
|
||||
return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +260,11 @@ public class ConversationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
public void setCondensedMode(boolean condensedMode) {
|
||||
this.condensedMode = condensedMode;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
switch (getItemViewType(position)) {
|
||||
@@ -284,10 +290,11 @@ public class ConversationAdapter
|
||||
recipient,
|
||||
searchQuery,
|
||||
conversationMessage == recordToPulse,
|
||||
hasWallpaper,
|
||||
hasWallpaper && !condensedMode,
|
||||
isMessageRequestAccepted,
|
||||
conversationMessage == inlineContent,
|
||||
colorizer);
|
||||
colorizer,
|
||||
condensedMode);
|
||||
|
||||
if (conversationMessage == recordToPulse) {
|
||||
recordToPulse = null;
|
||||
@@ -776,7 +783,7 @@ public class ConversationAdapter
|
||||
}
|
||||
}
|
||||
|
||||
interface ItemClickListener extends BindableConversationItem.EventListener {
|
||||
public interface ItemClickListener extends BindableConversationItem.EventListener {
|
||||
void onItemClick(MultiselectPart item);
|
||||
void onItemLongClick(View itemView, MultiselectPart item);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ import android.animation.Animator;
|
||||
import android.animation.LayoutTransition;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
@@ -49,7 +49,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.WindowDecorActionBar;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
@@ -104,6 +103,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
|
||||
import org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
@@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
@@ -161,6 +162,7 @@ import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.StoryViewerArgs;
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
@@ -201,7 +203,7 @@ import java.util.concurrent.ExecutionException;
|
||||
import kotlin.Unit;
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback {
|
||||
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback, MessageQuotesBottomSheet.Callback {
|
||||
private static final String TAG = Log.tag(ConversationFragment.class);
|
||||
|
||||
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
|
||||
@@ -1100,7 +1102,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
MultiselectForwardFragmentArgs.create(requireContext(),
|
||||
multiselectParts,
|
||||
args -> MultiselectForwardFragment.showBottomSheet(getChildFragmentManager(), args));
|
||||
args -> MultiselectForwardFragment.showBottomSheet(getChildFragmentManager(),
|
||||
args.withSendButtonTint(listener.getSendButtonTint())));
|
||||
}
|
||||
|
||||
private void handleResendMessage(final MessageRecord message) {
|
||||
@@ -1421,11 +1424,28 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSendMediaToStories() {
|
||||
return true;
|
||||
public @Nullable Stories.MediaTransform.SendRequirements getStorySendRequirements() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ItemClickListener getConversationAdapterListener() {
|
||||
return selectionClickListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void jumpToMessage(@NonNull MessageRecord messageRecord) {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
return SignalDatabase.mmsSms().getMessagePositionInConversation(threadId,
|
||||
messageRecord.getDateReceived(),
|
||||
messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId());
|
||||
}, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> {
|
||||
Toast.makeText(getContext(), R.string.ConversationFragment_failed_to_open_message, Toast.LENGTH_SHORT).show();
|
||||
}));
|
||||
}
|
||||
|
||||
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
|
||||
int getSendButtonTint();
|
||||
boolean isKeyboardOpen();
|
||||
boolean isAttachmentKeyboardOpen();
|
||||
void openAttachmentKeyboard();
|
||||
@@ -1702,6 +1722,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord) {
|
||||
if (getContext() != null && getActivity() != null) {
|
||||
MessageQuotesBottomSheet.show(
|
||||
getChildFragmentManager(),
|
||||
new MessageId(messageRecord.getId(), messageRecord.isMms()),
|
||||
recipient.getId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms) {
|
||||
if (getContext() != null && getActivity() != null) {
|
||||
@@ -1780,7 +1811,12 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Intent intent) {
|
||||
startActivityForResult(intent, CODE_ADD_EDIT_CONTACT);
|
||||
try {
|
||||
startActivityForResult(intent, CODE_ADD_EDIT_CONTACT);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "Could not locate contacts activity", e);
|
||||
Toast.makeText(requireContext(), R.string.ConversationFragment__contacts_app_not_found, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.util.LinkifyCompat;
|
||||
@@ -148,6 +149,8 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import kotlin.Unit;
|
||||
import kotlin.jvm.functions.Function1;
|
||||
@@ -173,6 +176,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
|
||||
private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100;
|
||||
private static final long MAX_CLUSTERING_TIME_DIFF = TimeUnit.MINUTES.toMillis(3);
|
||||
private static final int CONDENSED_MODE_MAX_LINES = 3;
|
||||
|
||||
private static final Pattern NOT_URL_PATTERN = Pattern.compile("[^a-zA-Z0-9-._~:/?#\\[\\]@!$&'()\\*+,;=]");
|
||||
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
@@ -182,6 +188,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private ValueAnimator pulseOutlinerAlphaAnimator;
|
||||
private Optional<MessageRecord> previousMessage;
|
||||
|
||||
/**
|
||||
* Whether or not we're rendering this item in a constrained space.
|
||||
* Today this is only {@link org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet}.
|
||||
*/
|
||||
private boolean isCondensedMode;
|
||||
|
||||
protected ConversationItemBodyBubble bodyBubble;
|
||||
protected View reply;
|
||||
@@ -199,6 +212,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
protected BadgeImageView badgeImageView;
|
||||
private View storyReactionLabelWrapper;
|
||||
private TextView storyReactionLabel;
|
||||
protected View quotedIndicator;
|
||||
|
||||
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
@@ -228,6 +242,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
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();
|
||||
@@ -259,6 +274,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
reactionsView.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
|
||||
if (quotedIndicator != null) {
|
||||
quotedIndicator.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,6 +328,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder);
|
||||
this.storyReactionLabel = findViewById(R.id.story_reacted_label);
|
||||
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
|
||||
this.quotedIndicator = findViewById(R.id.quoted_indicator);
|
||||
|
||||
setOnClickListener(new ClickListener(null));
|
||||
|
||||
@@ -329,7 +351,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean allowedToPlayInline,
|
||||
@NonNull Colorizer colorizer)
|
||||
@NonNull Colorizer colorizer,
|
||||
boolean isCondensedMode)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
|
||||
@@ -350,6 +373,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.canPlayContent = false;
|
||||
this.mediaItem = null;
|
||||
this.colorizer = colorizer;
|
||||
this.isCondensedMode = isCondensedMode;
|
||||
this.previousMessage = previousMessageRecord;
|
||||
|
||||
this.recipient.observeForever(this);
|
||||
this.conversationRecipient.observeForever(this);
|
||||
@@ -370,6 +395,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setReactions(messageRecord);
|
||||
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
|
||||
setStoryReactionLabel(messageRecord);
|
||||
setHasBeenQuoted(conversationMessage);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
@@ -388,6 +414,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent ev) {
|
||||
if (isCondensedMode) return super.dispatchTouchEvent(ev);
|
||||
|
||||
switch (ev.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS);
|
||||
@@ -401,6 +429,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
reactionsView.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f);
|
||||
|
||||
if (quotedIndicator != null) {
|
||||
quotedIndicator.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -774,10 +808,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
bodyBubble.setOutliners(outliners);
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.require().setPulseOutliner(pulseOutliner);
|
||||
}
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
setAudioViewTint(messageRecord);
|
||||
}
|
||||
@@ -868,6 +898,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not we want to condense the actual content of the bubble. e.g. shorten image height, text content, etc.
|
||||
* Today, we only want to do this for the first message when we're in condensed mode.
|
||||
*/
|
||||
private boolean isContentCondensed() {
|
||||
return isCondensedMode && !previousMessage.isPresent();
|
||||
}
|
||||
|
||||
private boolean isStoryReaction(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.isStoryReaction(messageRecord);
|
||||
}
|
||||
@@ -905,11 +943,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private boolean hasExtraText(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasExtraText(messageRecord);
|
||||
return MessageRecordUtil.hasExtraText(messageRecord) || isContentCondensed();
|
||||
}
|
||||
|
||||
private boolean hasQuote(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasQuote(messageRecord);
|
||||
return MessageRecordUtil.hasQuote(messageRecord) && (!isCondensedMode || !previousMessage.isPresent());
|
||||
}
|
||||
|
||||
private boolean hasSharedContact(MessageRecord messageRecord) {
|
||||
@@ -921,7 +959,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private boolean hasBigImageLinkPreview(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasBigImageLinkPreview(messageRecord, context);
|
||||
return MessageRecordUtil.hasBigImageLinkPreview(messageRecord, context) && !isContentCondensed();
|
||||
}
|
||||
|
||||
private boolean isViewOnceMessage(MessageRecord messageRecord) {
|
||||
@@ -975,6 +1013,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, ThemeUtil.isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
|
||||
}
|
||||
|
||||
if (isContentCondensed()) {
|
||||
bodyText.setMaxLines(CONDENSED_MODE_MAX_LINES);
|
||||
} else {
|
||||
bodyText.setMaxLines(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
bodyText.setText(StringUtil.trim(styledText));
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -1067,6 +1111,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (hasBigImageLinkPreview(messageRecord)) {
|
||||
mediaThumbnailStub.require().setVisibility(VISIBLE);
|
||||
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content));
|
||||
mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(R.dimen.media_bubble_max_height));
|
||||
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false);
|
||||
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
|
||||
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
|
||||
@@ -1081,7 +1126,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.setTopMargin(linkPreviewStub.get(), 0);
|
||||
} else {
|
||||
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true);
|
||||
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true, !isContentCondensed());
|
||||
linkPreviewStub.get().setDownloadClickedListener(downloadClickListener);
|
||||
setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false);
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
@@ -1184,11 +1229,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
|
||||
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
|
||||
: R.dimen.media_bubble_min_width_with_content));
|
||||
: R.dimen.media_bubble_min_width_with_content));
|
||||
mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(isCondensedMode ? R.dimen.media_bubble_max_height_condensed
|
||||
: R.dimen.media_bubble_max_height));
|
||||
mediaThumbnailStub.require().setImageResource(glideRequests,
|
||||
thumbnailSlides,
|
||||
showControls,
|
||||
@@ -1398,23 +1445,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private void linkifyMessageBody(@NonNull Spannable messageBody,
|
||||
boolean shouldLinkifyAllLinks)
|
||||
{
|
||||
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
|
||||
boolean hasLinks = LinkifyCompat.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
|
||||
|
||||
if (hasLinks) {
|
||||
Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class))
|
||||
.filterNot(url -> LinkUtil.isLegalUrl(url.getURL()))
|
||||
.forEach(messageBody::removeSpan);
|
||||
|
||||
URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class);
|
||||
|
||||
for (URLSpan urlSpan : urlSpans) {
|
||||
int start = messageBody.getSpanStart(urlSpan);
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickListener);
|
||||
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
|
||||
|
||||
if (conversationMessage.hasStyleLinks()) {
|
||||
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {
|
||||
@@ -1435,6 +1466,41 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static void linkifyUrlLinks(@NonNull Spannable messageBody, boolean shouldLinkifyAllLinks, @NonNull UrlClickHandler urlClickHandler) {
|
||||
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
|
||||
boolean hasLinks = LinkifyCompat.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
|
||||
|
||||
if (hasLinks) {
|
||||
URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class);
|
||||
|
||||
for (URLSpan urlSpan : urlSpans) {
|
||||
int start = messageBody.getSpanStart(urlSpan);
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
|
||||
Matcher matcher = NOT_URL_PATTERN.matcher(messageBody.toString().substring(end));
|
||||
if (matcher.find()) {
|
||||
int newEnd = end + matcher.start();
|
||||
URLSpan newSpan = new URLSpan(messageBody.toString().substring(start, newEnd));
|
||||
messageBody.removeSpan(urlSpan);
|
||||
messageBody.setSpan(newSpan, start, newEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
Stream.of(urlSpans)
|
||||
.filterNot(url -> LinkUtil.isLegalUrl(url.getURL()))
|
||||
.forEach(messageBody::removeSpan);
|
||||
|
||||
urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class);
|
||||
for (URLSpan urlSpan : urlSpans) {
|
||||
int start = messageBody.getSpanStart(urlSpan);
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickHandler);
|
||||
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setStatusIcons(MessageRecord messageRecord, boolean hasWallpaper) {
|
||||
bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0);
|
||||
|
||||
@@ -1457,7 +1523,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull ChatColors chatColors) {
|
||||
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
|
||||
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
|
||||
if (hasQuote(messageRecord)) {
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
@@ -1626,6 +1692,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
|
||||
if (message.hasBeenQuoted() && quotedIndicator != null) {
|
||||
quotedIndicator.setVisibility(VISIBLE);
|
||||
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
|
||||
} else if (quotedIndicator != null) {
|
||||
quotedIndicator.setVisibility(GONE);
|
||||
quotedIndicator.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
|
||||
return hasAudio(messageRecord);
|
||||
}
|
||||
@@ -2173,6 +2249,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private class QuotedIndicatorClickListener implements View.OnClickListener {
|
||||
public void onClick(final View view) {
|
||||
if (eventListener != null && batchSelected.isEmpty() && conversationMessage.hasBeenQuoted()) {
|
||||
eventListener.onQuotedIndicatorClicked((messageRecord));
|
||||
} else {
|
||||
passthroughClickListener.onClick(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AttachmentDownloadClickListener implements SlidesClickedListener {
|
||||
@Override
|
||||
public void onClick(View v, final List<Slide> slides) {
|
||||
|
||||
@@ -90,8 +90,8 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
public void onDrawForeground(Canvas canvas) {
|
||||
super.onDrawForeground(canvas);
|
||||
|
||||
if (Util.isEmpty(outliners)) return;
|
||||
|
||||
|
||||
@@ -32,16 +32,23 @@ public class ConversationMessage {
|
||||
@Nullable private final SpannableString body;
|
||||
@NonNull private final MultiselectCollection multiselectCollection;
|
||||
@NonNull private final MessageStyler.Result styleResult;
|
||||
private final int quotedCount;
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord) {
|
||||
this(messageRecord, null, null);
|
||||
this(messageRecord, null, null, 0);
|
||||
}
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord, int quotedCount) {
|
||||
this(messageRecord, null, null, quotedCount);
|
||||
}
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord,
|
||||
@Nullable CharSequence body,
|
||||
@Nullable List<Mention> mentions)
|
||||
@Nullable List<Mention> mentions,
|
||||
int quotedCount)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
this.quotedCount = quotedCount;
|
||||
this.mentions = mentions != null ? mentions : Collections.emptyList();
|
||||
|
||||
if (body != null) {
|
||||
@@ -77,6 +84,14 @@ public class ConversationMessage {
|
||||
return multiselectCollection;
|
||||
}
|
||||
|
||||
public boolean hasBeenQuoted() {
|
||||
return quotedCount > 0;
|
||||
}
|
||||
|
||||
public int getQuoteCount() {
|
||||
return quotedCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
@@ -119,8 +134,8 @@ public class ConversationMessage {
|
||||
* heavy work performed as the message is assumed to not have any mentions.
|
||||
*/
|
||||
@AnyThread
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord) {
|
||||
return new ConversationMessage(messageRecord);
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, int quotedCount) {
|
||||
return new ConversationMessage(messageRecord, quotedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,15 +143,16 @@ public class ConversationMessage {
|
||||
* 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 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 quotedCount The number of times a message has been quoted
|
||||
*/
|
||||
@AnyThread
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<Mention> mentions) {
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<Mention> mentions, int quotedCount) {
|
||||
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
|
||||
return new ConversationMessage(messageRecord, body, mentions);
|
||||
return new ConversationMessage(messageRecord, body, mentions, quotedCount);
|
||||
}
|
||||
return new ConversationMessage(messageRecord, body, null);
|
||||
return new ConversationMessage(messageRecord, body, null, quotedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,11 +163,13 @@ public class ConversationMessage {
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List<Mention> mentions) {
|
||||
int quotedCount = SignalDatabase.mmsSms().getQuotedCount(messageRecord);
|
||||
|
||||
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions);
|
||||
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions());
|
||||
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), quotedCount);
|
||||
}
|
||||
return createWithResolvedData(messageRecord);
|
||||
return createWithResolvedData(messageRecord, quotedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,14 +189,33 @@ public class ConversationMessage {
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) {
|
||||
int quotedCount = SignalDatabase.mmsSms().getQuotedCount(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());
|
||||
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), quotedCount);
|
||||
}
|
||||
}
|
||||
return createWithResolvedData(messageRecord, body, null);
|
||||
return createWithResolvedData(messageRecord, body, null, quotedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord and body, and will query for potential mentions. If mentions
|
||||
* are found, the body of the provided message will be updated and modified to match actual mentions. This will perform
|
||||
* 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, int quotedCount) {
|
||||
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(), quotedCount);
|
||||
}
|
||||
}
|
||||
return createWithResolvedData(messageRecord, body, null, quotedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1249,7 @@ public class ConversationParentFragment extends Fragment
|
||||
AttachmentManager.selectContactInfo(this, PICK_CONTACT);
|
||||
break;
|
||||
case LOCATION:
|
||||
AttachmentManager.selectLocation(this, PICK_LOCATION);
|
||||
AttachmentManager.selectLocation(this, PICK_LOCATION, getSendButtonColor(sendButton.getSelectedSendType()));
|
||||
break;
|
||||
case PAYMENT:
|
||||
if (recipient.get().hasProfileKeyCredential()) {
|
||||
@@ -2100,7 +2100,7 @@ public class ConversationParentFragment extends Fragment
|
||||
unverifiedBannerView.get().hide();
|
||||
}
|
||||
|
||||
titleView.setVerified(isSecureText && identityRecords.isVerified());
|
||||
titleView.setVerified(isSecureText && identityRecords.isVerified() && !recipient.get().isSelf());
|
||||
|
||||
future.set(true);
|
||||
}
|
||||
@@ -2634,7 +2634,7 @@ public class ConversationParentFragment extends Fragment
|
||||
|
||||
Log.i(TAG, "onModified(" + recipient.getId() + ") " + recipient.getRegistered());
|
||||
titleView.setTitle(glideRequests, recipient);
|
||||
titleView.setVerified(identityRecords.isVerified());
|
||||
titleView.setVerified(identityRecords.isVerified() && !recipient.isSelf());
|
||||
setBlockedUserState(recipient, isSecureText, isDefaultSms);
|
||||
updateReminders();
|
||||
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
|
||||
@@ -2743,7 +2743,7 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
|
||||
private void openContactShareEditor(Uri contactUri) {
|
||||
Intent intent = ContactShareEditActivity.getIntent(requireContext(), Collections.singletonList(contactUri));
|
||||
Intent intent = ContactShareEditActivity.getIntent(requireContext(), Collections.singletonList(contactUri), getSendButtonColor(sendButton.getSelectedSendType()));
|
||||
startActivityForResult(intent, GET_CONTACT_DETAILS);
|
||||
}
|
||||
|
||||
@@ -2771,7 +2771,7 @@ public class ConversationParentFragment extends Fragment
|
||||
numberItems[i] = contactData.numbers.get(i).type + ": " + contactData.numbers.get(i).number;
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext());
|
||||
builder.setIcon(R.drawable.ic_account_box);
|
||||
builder.setTitle(R.string.ConversationActivity_select_contact_info);
|
||||
|
||||
@@ -3249,7 +3249,7 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
|
||||
private void showDefaultSmsPrompt() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app)
|
||||
.setNegativeButton(R.string.ConversationActivity_no, (dialog, which) -> dialog.dismiss())
|
||||
.setPositiveButton(R.string.ConversationActivity_yes, (dialog, which) -> handleMakeDefaultSms())
|
||||
@@ -3953,6 +3953,11 @@ public class ConversationParentFragment extends Fragment
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSendButtonTint() {
|
||||
return getSendButtonColor(sendButton.getSelectedSendType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isKeyboardOpen() {
|
||||
return container.isKeyboardOpen();
|
||||
@@ -4075,7 +4080,7 @@ public class ConversationParentFragment extends Fragment
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext())
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext())
|
||||
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss());
|
||||
|
||||
if (recipient.isGroup() && recipient.isBlocked()) {
|
||||
@@ -4199,7 +4204,7 @@ public class ConversationParentFragment extends Fragment
|
||||
unverifiedNames[i] = Recipient.resolved(unverifiedIdentities.get(i).getRecipientId()).getDisplayName(requireContext());
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext());
|
||||
builder.setIcon(R.drawable.ic_warning);
|
||||
builder.setTitle("No longer verified");
|
||||
builder.setItems(unverifiedNames, (dialog, which) -> {
|
||||
|
||||
@@ -25,6 +25,7 @@ final class ConversationSwipeAnimationHelper {
|
||||
private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10));
|
||||
private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8));
|
||||
private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX);
|
||||
private static final Interpolator QUOTED_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(1f, 0f, 3f);
|
||||
|
||||
private ConversationSwipeAnimationHelper() {
|
||||
}
|
||||
@@ -34,6 +35,7 @@ final class ConversationSwipeAnimationHelper {
|
||||
|
||||
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
|
||||
updateReactionsTransition(conversationItem.reactionsView, dx, sign);
|
||||
updateQuotedIndicatorTransition(conversationItem.quotedIndicator, dx, progress, sign);
|
||||
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
|
||||
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
|
||||
updateContactPhotoHolderTransition(conversationItem.badgeImageView, progress, sign);
|
||||
@@ -51,6 +53,13 @@ final class ConversationSwipeAnimationHelper {
|
||||
reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
|
||||
}
|
||||
|
||||
private static void updateQuotedIndicatorTransition(@Nullable View quotedIndicator, float dx, float progress, float sign) {
|
||||
if (quotedIndicator != null) {
|
||||
quotedIndicator.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
|
||||
quotedIndicator.setAlpha(QUOTED_ALPHA_INTERPOLATOR.getInterpolation(progress) * sign);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) {
|
||||
if (progress > 0.05f) {
|
||||
replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress));
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -30,6 +32,9 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class ConversationTitleView extends RelativeLayout {
|
||||
|
||||
private static final String STATE_ROOT = "root";
|
||||
private static final String STATE_IS_SELF = "is_self";
|
||||
|
||||
private AvatarView avatar;
|
||||
private BadgeImageView badge;
|
||||
private TextView title;
|
||||
@@ -39,6 +44,7 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
private View verifiedSubtitle;
|
||||
private View expirationBadgeContainer;
|
||||
private TextView expirationBadgeTime;
|
||||
private boolean isSelf;
|
||||
|
||||
public ConversationTitleView(Context context) {
|
||||
this(context, null);
|
||||
@@ -66,7 +72,33 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
ViewUtil.setTextViewGravityStart(this.subtitle, getContext());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected @NonNull Parcelable onSaveInstanceState() {
|
||||
Bundle bundle = new Bundle();
|
||||
|
||||
bundle.putParcelable(STATE_ROOT, super.onSaveInstanceState());
|
||||
bundle.putBoolean(STATE_IS_SELF, isSelf);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Parcelable state) {
|
||||
if (state instanceof Bundle) {
|
||||
Parcelable rootState = ((Bundle) state).getParcelable(STATE_ROOT);
|
||||
super.onRestoreInstanceState(rootState);
|
||||
|
||||
isSelf = ((Bundle) state).getBoolean(STATE_IS_SELF, false);
|
||||
} else {
|
||||
super.onRestoreInstanceState(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void showExpiring(@NonNull LiveRecipient recipient) {
|
||||
isSelf = recipient.get().isSelf();
|
||||
|
||||
expirationBadgeTime.setText(ExpirationUtil.getExpirationAbbreviatedDisplayValue(getContext(), recipient.get().getExpiresInSeconds()));
|
||||
expirationBadgeContainer.setVisibility(View.VISIBLE);
|
||||
updateSubtitleVisibility();
|
||||
@@ -78,6 +110,8 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
}
|
||||
|
||||
public void setTitle(@NonNull GlideRequests glideRequests, @Nullable Recipient recipient) {
|
||||
isSelf = recipient != null && recipient.isSelf();
|
||||
|
||||
this.subtitleContainer.setVisibility(View.VISIBLE);
|
||||
|
||||
if (recipient == null) setComposeTitle();
|
||||
@@ -169,7 +203,7 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
|
||||
private void setSelfTitle() {
|
||||
this.title.setText(R.string.note_to_self);
|
||||
this.subtitleContainer.setVisibility(View.GONE);
|
||||
updateSubtitleVisibility();
|
||||
}
|
||||
|
||||
private void setIndividualRecipientTitle(@NonNull Recipient recipient) {
|
||||
@@ -177,15 +211,14 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
this.title.setText(displayName);
|
||||
this.subtitle.setText(null);
|
||||
updateSubtitleVisibility();
|
||||
updateVerifiedSubtitleVisibility();
|
||||
}
|
||||
|
||||
private void updateVerifiedSubtitleVisibility() {
|
||||
verifiedSubtitle.setVisibility(subtitle.getVisibility() != VISIBLE && verified.getVisibility() == VISIBLE ? VISIBLE : GONE);
|
||||
verifiedSubtitle.setVisibility(!isSelf && subtitle.getVisibility() != VISIBLE && verified.getVisibility() == VISIBLE ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
private void updateSubtitleVisibility() {
|
||||
subtitle.setVisibility(expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
subtitle.setVisibility(!isSelf && expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
updateVerifiedSubtitleVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
|
||||
private TextView body;
|
||||
private MaterialButton actionButton;
|
||||
private Stub<CardView> donateButtonStub;
|
||||
private View background;
|
||||
private ConversationMessage conversationMessage;
|
||||
private Recipient conversationRecipient;
|
||||
@@ -105,10 +104,9 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
@Override
|
||||
public void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
this.body = findViewById(R.id.conversation_update_body);
|
||||
this.actionButton = findViewById(R.id.conversation_update_action);
|
||||
this.donateButtonStub = ViewUtil.findStubById(this, R.id.conversation_update_donate_action);
|
||||
this.background = findViewById(R.id.conversation_update_background);
|
||||
this.body = findViewById(R.id.conversation_update_body);
|
||||
this.actionButton = findViewById(R.id.conversation_update_action);
|
||||
this.background = findViewById(R.id.conversation_update_background);
|
||||
|
||||
body.setOnClickListener(v -> performClick());
|
||||
body.setOnLongClickListener(v -> performLongClick());
|
||||
@@ -130,7 +128,8 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean allowedToPlayInline,
|
||||
@NonNull Colorizer colorizer)
|
||||
@NonNull Colorizer colorizer,
|
||||
boolean isCondensedMode)
|
||||
{
|
||||
this.batchSelected = batchSelected;
|
||||
|
||||
@@ -189,7 +188,7 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
shouldCollapse(messageRecord, nextMessageRecord),
|
||||
hasWallpaper);
|
||||
|
||||
presentActionButton(hasWallpaper);
|
||||
presentActionButton(hasWallpaper, conversationMessage.getMessageRecord().isBoostRequest());
|
||||
|
||||
updateSelectedState();
|
||||
}
|
||||
@@ -531,37 +530,24 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
eventListener.onBlockJoinRequest(conversationMessage.getMessageRecord().getIndividualRecipient());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionButton.setVisibility(GONE);
|
||||
actionButton.setOnClickListener(null);
|
||||
}
|
||||
|
||||
if (conversationMessage.getMessageRecord().isBoostRequest()) {
|
||||
actionButton.setVisibility(GONE);
|
||||
|
||||
CardView donateButton = donateButtonStub.get();
|
||||
TextView buttonText = donateButton.findViewById(R.id.conversation_update_donate_action_button);
|
||||
boolean isSustainer = SignalStore.donationsValues().isLikelyASustainer();
|
||||
|
||||
donateButton.setVisibility(VISIBLE);
|
||||
donateButton.setOnClickListener(v -> {
|
||||
} else if (conversationMessage.getMessageRecord().isBoostRequest()) {
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setOnClickListener(v -> {
|
||||
if (batchSelected.isEmpty() && eventListener != null) {
|
||||
eventListener.onDonateClicked();
|
||||
}
|
||||
});
|
||||
|
||||
if (isSustainer) {
|
||||
buttonText.setText(R.string.ConversationUpdateItem_signal_boost);
|
||||
buttonText.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_boost_outline_16, 0, 0, 0);
|
||||
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||
actionButton.setText(R.string.ConversationUpdateItem_signal_boost);
|
||||
actionButton.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_boost_outline_16, 0, 0, 0);
|
||||
} else {
|
||||
buttonText.setText(R.string.ConversationUpdateItem_become_a_sustainer);
|
||||
buttonText.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
|
||||
actionButton.setText(R.string.ConversationUpdateItem_become_a_sustainer);
|
||||
actionButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
AutoRounder.autoSetCorners(donateButton, donateButton::setRadius);
|
||||
|
||||
} else if (donateButtonStub.resolved()) {
|
||||
donateButtonStub.get().setVisibility(GONE);
|
||||
} else {
|
||||
actionButton.setVisibility(GONE);
|
||||
actionButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,8 +627,11 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
}
|
||||
|
||||
private void presentActionButton(boolean hasWallpaper) {
|
||||
if (hasWallpaper) {
|
||||
private void presentActionButton(boolean hasWallpaper, boolean isBoostRequest) {
|
||||
if (isBoostRequest) {
|
||||
actionButton.setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.signal_colorSecondaryContainer)));
|
||||
actionButton.setTextColor(ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.signal_colorOnSecondaryContainer)));
|
||||
} else if (hasWallpaper) {
|
||||
actionButton.setBackgroundTintList(AppCompatResources.getColorStateList(getContext(), R.color.conversation_update_item_button_background_wallpaper));
|
||||
actionButton.setTextColor(AppCompatResources.getColorStateList(getContext(), R.color.conversation_update_item_button_text_color_wallpaper));
|
||||
} else {
|
||||
|
||||
@@ -13,12 +13,9 @@ import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.MapUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.ObservablePagedData;
|
||||
import org.signal.paging.PagedData;
|
||||
@@ -28,15 +25,12 @@ import org.signal.paging.ProxyPagingController;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
|
||||
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper;
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository;
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
|
||||
@@ -53,19 +47,15 @@ import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
|
||||
@@ -97,8 +87,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final Observer<ThreadAnimationState> threadAnimationStateStoreDriver;
|
||||
private final NotificationProfilesRepository notificationProfilesRepository;
|
||||
private final MutableLiveData<String> searchQuery;
|
||||
|
||||
private final Map<GroupId, Set<Recipient>> sessionMemberCache = new HashMap<>();
|
||||
private final GroupAuthorNameColorHelper groupAuthorNameColorHelper;
|
||||
|
||||
private ConversationIntents.Args args;
|
||||
private int jumpToPosition;
|
||||
@@ -123,6 +112,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
this.searchQuery = new MutableLiveData<>();
|
||||
this.recipientId = BehaviorSubject.create();
|
||||
this.threadId = BehaviorSubject.create();
|
||||
this.groupAuthorNameColorHelper = new GroupAuthorNameColorHelper();
|
||||
|
||||
BehaviorSubject<Recipient> recipientCache = BehaviorSubject.create();
|
||||
|
||||
@@ -345,35 +335,13 @@ public class ConversationViewModel extends ViewModel {
|
||||
.observeOn(Schedulers.io())
|
||||
.distinctUntilChanged()
|
||||
.map(Recipient::resolved)
|
||||
.map(Recipient::getGroupId)
|
||||
.map(groupId -> {
|
||||
if (groupId.isPresent()) {
|
||||
List<Recipient> fullMembers = SignalDatabase.groups().getGroupMembers(groupId.get(), GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF);
|
||||
Set<Recipient> cachedMembers = MapUtil.getOrDefault(sessionMemberCache, groupId.get(), new HashSet<>());
|
||||
|
||||
cachedMembers.addAll(fullMembers);
|
||||
sessionMemberCache.put(groupId.get(), cachedMembers);
|
||||
|
||||
return cachedMembers;
|
||||
.map(recipient -> {
|
||||
if (recipient.getGroupId().isPresent()) {
|
||||
return groupAuthorNameColorHelper.getColorMap(recipient.getGroupId().get());
|
||||
} else {
|
||||
return Collections.<Recipient>emptySet();
|
||||
return Collections.<RecipientId, NameColor>emptyMap();
|
||||
}
|
||||
})
|
||||
.map(members -> {
|
||||
List<Recipient> sorted = Stream.of(members)
|
||||
.filter(member -> !Objects.equals(member, Recipient.self()))
|
||||
.sortBy(Recipient::requireStringId)
|
||||
.toList();
|
||||
|
||||
List<NameColor> names = ChatColorsPalette.Names.getAll();
|
||||
Map<RecipientId, NameColor> colors = new HashMap<>();
|
||||
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
colors.put(sorted.get(i).getId(), names.get(i % names.size()));
|
||||
}
|
||||
|
||||
return colors;
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.conversation
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
|
||||
|
||||
@@ -19,16 +21,15 @@ object MessageStyler {
|
||||
|
||||
for (range in messageRanges.rangesList) {
|
||||
if (range.hasStyle()) {
|
||||
val style = range.style?.let {
|
||||
when (it) {
|
||||
BodyRangeList.BodyRange.Style.BOLD -> Typeface.BOLD
|
||||
BodyRangeList.BodyRange.Style.ITALIC -> Typeface.ITALIC
|
||||
BodyRangeList.BodyRange.Style.UNRECOGNIZED -> Typeface.NORMAL
|
||||
}
|
||||
|
||||
val styleSpan: CharacterStyle? = when (range.style) {
|
||||
BodyRangeList.BodyRange.Style.BOLD -> TypefaceSpan("sans-serif-medium")
|
||||
BodyRangeList.BodyRange.Style.ITALIC -> StyleSpan(Typeface.ITALIC)
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (style != null && style != Typeface.NORMAL) {
|
||||
span.setSpan(StyleSpan(style), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors
|
||||
|
||||
import androidx.annotation.NonNull
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Class to assist managing the colors of author names in the UI in groups.
|
||||
* We want to be able to map each group member to a color, and for that to
|
||||
* remain constant throughout a "chat open lifecycle" (i.e. should never
|
||||
* change while looking at a chat, but can change if you close and open).
|
||||
*/
|
||||
class GroupAuthorNameColorHelper {
|
||||
|
||||
/** Needed so that we have a full history of current *and* past members (so colors don't change when someone leaves) */
|
||||
private val fullMemberCache: MutableMap<GroupId, Set<Recipient>> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Given a [GroupId], returns a map of member -> name color.
|
||||
*/
|
||||
fun getColorMap(@NonNull groupId: GroupId): Map<RecipientId, NameColor> {
|
||||
val dbMembers: Set<Recipient> = SignalDatabase
|
||||
.groups
|
||||
.getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
|
||||
.toSet()
|
||||
val cachedMembers: Set<Recipient> = fullMemberCache.getOrDefault(groupId, setOf())
|
||||
val allMembers: Set<Recipient> = cachedMembers + dbMembers
|
||||
|
||||
fullMemberCache[groupId] = allMembers
|
||||
|
||||
val members: List<Recipient> = allMembers
|
||||
.filter { member -> member != Recipient.self() }
|
||||
.sortedBy { obj: Recipient -> obj.requireStringId() }
|
||||
|
||||
val allColors: List<NameColor> = ChatColorsPalette.Names.all
|
||||
|
||||
val colors: MutableMap<RecipientId, NameColor> = HashMap()
|
||||
for (i in members.indices) {
|
||||
colors[members[i].id] = allColors[i % allColors.size]
|
||||
}
|
||||
|
||||
return colors.toMap()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), MultiselectForwardFragment.Callback {
|
||||
@@ -43,10 +44,9 @@ class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
override fun canSendMediaToStories(): Boolean {
|
||||
return findListener<Callback>()?.canSendMediaToStories() ?: true
|
||||
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
|
||||
return findListener<Callback>()?.getStorySendRequirements()
|
||||
}
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
setFragmentResult(MultiselectForwardFragment.RESULT_KEY, bundle)
|
||||
}
|
||||
@@ -71,6 +71,6 @@ class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
interface Callback {
|
||||
fun onFinishForwardAction()
|
||||
fun onDismissForwardSheet()
|
||||
fun canSendMediaToStories(): Boolean = true
|
||||
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
@@ -13,6 +14,9 @@ import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
@@ -24,6 +28,8 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup
|
||||
import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
|
||||
@@ -42,7 +48,7 @@ import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.privacy.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper
|
||||
@@ -70,7 +76,8 @@ import org.thoughtcrime.securesms.util.visible
|
||||
class MultiselectForwardFragment :
|
||||
Fragment(R.layout.multiselect_forward_fragment),
|
||||
SafetyNumberChangeDialog.Callback,
|
||||
ChooseStoryTypeBottomSheet.Callback {
|
||||
ChooseStoryTypeBottomSheet.Callback,
|
||||
WrapperDialogFragment.WrapperDialogFragmentCallback {
|
||||
|
||||
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
|
||||
private val disposables = LifecycleDisposable()
|
||||
@@ -78,6 +85,7 @@ class MultiselectForwardFragment :
|
||||
private lateinit var contactFilterView: ContactFilterView
|
||||
private lateinit var addMessage: EditText
|
||||
private lateinit var contactSearchMediator: ContactSearchMediator
|
||||
private lateinit var contactSearchRecycler: RecyclerView
|
||||
|
||||
private lateinit var callback: Callback
|
||||
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
|
||||
@@ -95,6 +103,9 @@ class MultiselectForwardFragment :
|
||||
private val isSelectionOnly: Boolean
|
||||
get() = requireArguments().getBoolean(ARG_FORCE_SELECTION_ONLY, false)
|
||||
|
||||
private val sendButtonTint: Int
|
||||
get() = requireArguments().getInt(ARG_SEND_BUTTON_TINT, -1)
|
||||
|
||||
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
|
||||
return if (parentFragment != null) {
|
||||
requireParentFragment().onGetLayoutInflater(savedInstanceState)
|
||||
@@ -106,8 +117,8 @@ class MultiselectForwardFragment :
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.minimumHeight = resources.displayMetrics.heightPixels
|
||||
|
||||
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration)
|
||||
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration, this::filterContacts)
|
||||
|
||||
callback = findListener()!!
|
||||
disposables.bindTo(viewLifecycleOwner.lifecycle)
|
||||
@@ -135,6 +146,10 @@ class MultiselectForwardFragment :
|
||||
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
|
||||
val backgroundHelper: View = bottomBar.findViewById(R.id.background_helper)
|
||||
|
||||
if (sendButtonTint != -1) {
|
||||
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(sendButtonTint))
|
||||
}
|
||||
|
||||
FullscreenHelper.configureBottomBarLayout(requireActivity(), bottomBarSpacer, bottomBar)
|
||||
|
||||
backgroundHelper.setBackgroundColor(callback.getDialogBackgroundColor())
|
||||
@@ -347,6 +362,51 @@ class MultiselectForwardFragment :
|
||||
viewModel.cancelSend()
|
||||
}
|
||||
|
||||
private fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements {
|
||||
return requireListener<Callback>().getStorySendRequirements() ?: viewModel.snapshot.storySendRequirements
|
||||
}
|
||||
|
||||
private fun filterContacts(view: View?, contactSet: Set<ContactSearchKey>): Set<ContactSearchKey> {
|
||||
val storySendRequirements = getStorySendRequirements()
|
||||
val resultsSet = contactSet.filterNot {
|
||||
it is ContactSearchKey.RecipientSearchKey && it.isStory && storySendRequirements == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
}
|
||||
|
||||
if (view != null && contactSet.any { it is ContactSearchKey.RecipientSearchKey && it.isStory }) {
|
||||
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
|
||||
when (storySendRequirements) {
|
||||
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> {
|
||||
if (!SignalStore.storyValues().videoTooltipSeen) {
|
||||
displayTooltip(view, R.string.MultiselectForwardFragment__videos_will_be_trimmed) {
|
||||
SignalStore.storyValues().videoTooltipSeen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> {
|
||||
if (!SignalStore.storyValues().cannotSendTooltipSeen) {
|
||||
displayTooltip(view, R.string.MultiselectForwardFragment__videos_sent_to_stories_cant) {
|
||||
SignalStore.storyValues().cannotSendTooltipSeen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultsSet.toSet()
|
||||
}
|
||||
|
||||
private fun displayTooltip(anchor: View, @StringRes text: Int, onDismiss: () -> Unit) {
|
||||
TooltipPopup
|
||||
.forTarget(anchor)
|
||||
.setText(text)
|
||||
.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_colorOnPrimary))
|
||||
.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
|
||||
.setOnDismissListener {
|
||||
onDismiss()
|
||||
}
|
||||
.show(TooltipPopup.POSITION_BELOW)
|
||||
}
|
||||
|
||||
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
|
||||
return findListener<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
|
||||
query = contactSearchState.query
|
||||
@@ -375,7 +435,8 @@ class MultiselectForwardFragment :
|
||||
if (query.isNullOrEmpty()) {
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Recents(
|
||||
includeHeader = true
|
||||
includeHeader = true,
|
||||
includeSelf = true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -407,7 +468,7 @@ class MultiselectForwardFragment :
|
||||
}
|
||||
|
||||
private fun isSelectedMediaValidForStories(): Boolean {
|
||||
return requireListener<Callback>().canSendMediaToStories() && getMultiShareArgs().all { it.isValidForStories }
|
||||
return getMultiShareArgs().all { it.isValidForStories }
|
||||
}
|
||||
|
||||
private fun isSelectedMediaValidForNonStories(): Boolean {
|
||||
@@ -429,7 +490,7 @@ class MultiselectForwardFragment :
|
||||
fun setResult(bundle: Bundle)
|
||||
fun getContainer(): ViewGroup
|
||||
fun getDialogBackgroundColor(): Int
|
||||
fun canSendMediaToStories(): Boolean = true
|
||||
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -439,6 +500,7 @@ class MultiselectForwardFragment :
|
||||
const val ARG_FORCE_DISABLE_ADD_MESSAGE = "multiselect.forward.fragment.force.disable.add.message"
|
||||
const val ARG_FORCE_SELECTION_ONLY = "multiselect.forward.fragment.force.disable.add.message"
|
||||
const val ARG_SELECT_SINGLE_RECIPIENT = "multiselect.forward.framgent.select.single.recipient"
|
||||
const val ARG_SEND_BUTTON_TINT = "multiselect.forward.fragment.send.button.tint"
|
||||
const val RESULT_KEY = "result_key"
|
||||
const val RESULT_SELECTION = "result_selection_recipients"
|
||||
const val RESULT_SENT = "result_sent"
|
||||
@@ -478,7 +540,12 @@ class MultiselectForwardFragment :
|
||||
putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage)
|
||||
putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly)
|
||||
putBoolean(ARG_SELECT_SINGLE_RECIPIENT, multiselectForwardFragmentArgs.selectSingleRecipient)
|
||||
putInt(ARG_SEND_BUTTON_TINT, multiselectForwardFragmentArgs.sendButtonTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWrapperDialogFragmentDismissed() {
|
||||
contactSearchMediator.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.StreamUtil
|
||||
@@ -30,15 +31,18 @@ import java.util.function.Consumer
|
||||
* @param forceSelectionOnly Force the fragment to only select recipients, never actually performing the send.
|
||||
* @param selectSingleRecipient Only allow the selection of a single recipient.
|
||||
*/
|
||||
class MultiselectForwardFragmentArgs @JvmOverloads constructor(
|
||||
data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
|
||||
val canSendToNonPush: Boolean,
|
||||
val multiShareArgs: List<MultiShareArgs> = listOf(),
|
||||
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
|
||||
val forceDisableAddMessage: Boolean = false,
|
||||
val forceSelectionOnly: Boolean = false,
|
||||
val selectSingleRecipient: Boolean = false
|
||||
val selectSingleRecipient: Boolean = false,
|
||||
@ColorInt val sendButtonTint: Int = -1
|
||||
) {
|
||||
|
||||
fun withSendButtonTint(@ColorInt sendButtonTint: Int) = copy(sendButtonTint = sendButtonTint)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun create(context: Context, mediaUri: Uri, mediaType: String, consumer: Consumer<MultiselectForwardFragmentArgs>) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback {
|
||||
@@ -33,6 +34,10 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M
|
||||
return ContextCompat.getColor(requireContext(), R.color.signal_background_primary)
|
||||
}
|
||||
|
||||
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
|
||||
return findListener<Callback>()?.getStorySendRequirements()
|
||||
}
|
||||
|
||||
override fun getContainer(): ViewGroup {
|
||||
return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup
|
||||
}
|
||||
@@ -47,12 +52,8 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M
|
||||
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun canSendMediaToStories(): Boolean {
|
||||
return findListener<Callback>()?.canSendMediaToStories() ?: true
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onFinishForwardAction() = Unit
|
||||
fun canSendMediaToStories(): Boolean = true
|
||||
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -8,6 +9,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareSender
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import java.util.Optional
|
||||
|
||||
class MultiselectForwardRepository {
|
||||
@@ -18,6 +20,20 @@ class MultiselectForwardRepository {
|
||||
val onAllMessagesFailed: () -> Unit
|
||||
)
|
||||
|
||||
fun checkAllSelectedMediaCanBeSentToStories(records: List<MultiShareArgs>): Single<Stories.MediaTransform.SendRequirements> {
|
||||
if (!Stories.isFeatureEnabled() || records.isEmpty()) {
|
||||
return Single.just(Stories.MediaTransform.SendRequirements.CAN_NOT_SEND)
|
||||
}
|
||||
|
||||
return Single.fromCallable {
|
||||
if (records.any { !it.isValidForStories }) {
|
||||
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
} else {
|
||||
Stories.MediaTransform.getSendRequirements(records.map { it.media }.flatten())
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun canSelectRecipient(recipientId: Optional<RecipientId>): Single<Boolean> {
|
||||
if (!recipientId.isPresent) {
|
||||
return Single.just(true)
|
||||
|
||||
@@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
|
||||
data class MultiselectForwardState(
|
||||
val stage: Stage = Stage.Selection
|
||||
val stage: Stage = Stage.Selection,
|
||||
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
) {
|
||||
|
||||
sealed class Stage {
|
||||
object Selection : Stage()
|
||||
object FirstConfirmation : Stage()
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
|
||||
@@ -18,6 +20,19 @@ class MultiselectForwardViewModel(
|
||||
private val store = Store(MultiselectForwardState())
|
||||
|
||||
val state: LiveData<MultiselectForwardState> = store.stateLiveData
|
||||
val snapshot: MultiselectForwardState get() = store.state
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposables += repository.checkAllSelectedMediaCanBeSentToStories(records).subscribe { sendRequirements ->
|
||||
store.update { it.copy(storySendRequirements = sendRequirements) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun send(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
|
||||
if (SignalStore.tooltips().showMultiForwardDialog()) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.conversation.quotes
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.marginEnd
|
||||
import androidx.core.view.marginLeft
|
||||
import androidx.core.view.marginStart
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Serves as the separator between the original message and the messages that quote it in [MessageQuotesBottomSheet]
|
||||
*/
|
||||
class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val dividerMargin = ViewUtil.dpToPx(context, 32)
|
||||
private val dividerHeight = ViewUtil.dpToPx(context, 2)
|
||||
private val dividerRect = Rect()
|
||||
private val dividerPaint: Paint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
color = context.resources.getColor(R.color.signal_colorSurfaceVariant)
|
||||
}
|
||||
|
||||
private var cachedHeader: View? = null
|
||||
private val headerMargin = ViewUtil.dpToPx(24)
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val lastItem: View = parent.children.firstOrNull { child ->
|
||||
parent.getChildAdapterPosition(child) == state.itemCount - 1
|
||||
} ?: return
|
||||
|
||||
dividerRect.apply {
|
||||
left = parent.left
|
||||
top = lastItem.bottom + dividerMargin
|
||||
right = parent.right
|
||||
bottom = lastItem.bottom + dividerMargin + dividerHeight
|
||||
}
|
||||
|
||||
canvas.drawRect(dividerRect, dividerPaint)
|
||||
|
||||
val header = getHeader(parent)
|
||||
|
||||
canvas.save()
|
||||
canvas.translate((parent.left + header.marginLeft).toFloat(), (dividerRect.bottom + dividerMargin).toFloat())
|
||||
header.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun getHeader(parent: RecyclerView): View {
|
||||
cachedHeader?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val header: View = LayoutInflater.from(parent.context).inflate(R.layout.message_quote_header_decoration, parent, false)
|
||||
|
||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width - header.marginStart - header.marginEnd, View.MeasureSpec.EXACTLY)
|
||||
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
|
||||
|
||||
val childWidth = ViewGroup.getChildMeasureSpec(
|
||||
widthSpec,
|
||||
parent.paddingLeft + parent.paddingRight,
|
||||
header.layoutParams.width
|
||||
)
|
||||
|
||||
val childHeight = ViewGroup.getChildMeasureSpec(
|
||||
heightSpec,
|
||||
parent.paddingTop + parent.paddingBottom,
|
||||
header.layoutParams.height
|
||||
)
|
||||
|
||||
header.measure(childWidth, childHeight)
|
||||
header.layout(header.marginLeft, 0, header.measuredWidth, header.measuredHeight)
|
||||
|
||||
cachedHeader = header
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val currentPosition = parent.getChildAdapterPosition(view)
|
||||
val lastPosition = state.itemCount - 1
|
||||
|
||||
if (currentPosition == lastPosition) {
|
||||
outRect.bottom = ViewUtil.dpToPx(view.context, 110)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package org.thoughtcrime.securesms.conversation.quotes
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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 org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
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.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import java.util.Locale
|
||||
|
||||
class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private lateinit var messageAdapter: ConversationAdapter
|
||||
private val viewModel: MessageQuotesViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val messageId = MessageId.deserialize(arguments?.getString(KEY_MESSAGE_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
MessageQuotesViewModel.Factory(ApplicationDependencies.getApplication(), messageId, conversationRecipientId)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.message_quotes_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)
|
||||
|
||||
val colorizer = Colorizer()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
|
||||
setCondensedMode(true)
|
||||
}
|
||||
|
||||
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.quotes_list).apply {
|
||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
addItemDecoration(MessageQuoteHeaderDecoration(context))
|
||||
|
||||
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().subscribe { messages ->
|
||||
messageAdapter.submitList(messages) {
|
||||
(list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100)
|
||||
}
|
||||
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
|
||||
}
|
||||
|
||||
disposables += viewModel.getNameColorsMap().subscribe { map ->
|
||||
colorizer.onNameColorsChanged(map)
|
||||
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallback(): Callback {
|
||||
return findListener<Callback>() ?: throw IllegalStateException("Parent must implement callback interface!")
|
||||
}
|
||||
|
||||
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
|
||||
return getCallback().getConversationAdapterListener()
|
||||
}
|
||||
|
||||
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
|
||||
override fun onItemClick(item: MultiselectPart) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(item.getMessageRecord())
|
||||
}
|
||||
|
||||
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
|
||||
onItemClick(item)
|
||||
}
|
||||
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(messageRecord)
|
||||
}
|
||||
|
||||
override fun onLinkPreviewClicked(linkPreview: LinkPreview) {
|
||||
dismiss()
|
||||
getAdapterListener().onLinkPreviewClicked(linkPreview)
|
||||
}
|
||||
|
||||
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onQuotedIndicatorClicked(messageRecord)
|
||||
}
|
||||
|
||||
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) {
|
||||
dismiss()
|
||||
getAdapterListener().onReactionClicked(multiselectPart, messageId, isMms)
|
||||
}
|
||||
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
|
||||
dismiss()
|
||||
getAdapterListener().onGroupMemberClicked(recipientId, groupId)
|
||||
}
|
||||
|
||||
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onMessageWithRecaptchaNeededClicked(messageRecord)
|
||||
}
|
||||
|
||||
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) {
|
||||
dismiss()
|
||||
getAdapterListener().onGroupMigrationLearnMoreClicked(membershipChange)
|
||||
}
|
||||
|
||||
override fun onChatSessionRefreshLearnMoreClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onChatSessionRefreshLearnMoreClicked()
|
||||
}
|
||||
|
||||
override fun onBadDecryptLearnMoreClicked(author: RecipientId) {
|
||||
dismiss()
|
||||
getAdapterListener().onBadDecryptLearnMoreClicked(author)
|
||||
}
|
||||
|
||||
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) {
|
||||
dismiss()
|
||||
getAdapterListener().onSafetyNumberLearnMoreClicked(recipient)
|
||||
}
|
||||
|
||||
override fun onJoinGroupCallClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onJoinGroupCallClicked()
|
||||
}
|
||||
|
||||
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
|
||||
dismiss()
|
||||
getAdapterListener().onInviteFriendsToGroupClicked(groupId)
|
||||
}
|
||||
|
||||
override fun onEnableCallNotificationsClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onEnableCallNotificationsClicked()
|
||||
}
|
||||
|
||||
override fun onCallToAction(action: String) {
|
||||
dismiss()
|
||||
getAdapterListener().onCallToAction(action)
|
||||
}
|
||||
|
||||
override fun onDonateClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onDonateClicked()
|
||||
}
|
||||
|
||||
override fun onRecipientNameClicked(target: RecipientId) {
|
||||
dismiss()
|
||||
getAdapterListener().onRecipientNameClicked(target)
|
||||
}
|
||||
|
||||
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onViewGiftBadgeClicked(messageRecord)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
|
||||
fun jumpToMessage(messageRecord: MessageRecord)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_MESSAGE_ID = "message_id"
|
||||
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, messageId: MessageId, conversationRecipientId: RecipientId) {
|
||||
val args = Bundle().apply {
|
||||
putString(KEY_MESSAGE_ID, messageId.serialize())
|
||||
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
|
||||
}
|
||||
|
||||
val fragment = MessageQuotesBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.conversation.quotes
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class MessageQuotesViewModel(
|
||||
application: Application,
|
||||
private val messageId: MessageId,
|
||||
private val conversationRecipientId: RecipientId
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
|
||||
|
||||
fun getMessages(): Observable<List<ConversationMessage>> {
|
||||
return Observable.create<List<ConversationMessage>> { emitter ->
|
||||
val quotes: List<ConversationMessage> = SignalDatabase
|
||||
.mmsSms
|
||||
.getAllMessagesThatQuote(messageId)
|
||||
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(getApplication(), it) }
|
||||
|
||||
val originalRecord: MessageRecord? = if (messageId.mms) {
|
||||
SignalDatabase.mms.getMessageRecordOrNull(messageId.id)
|
||||
} else {
|
||||
SignalDatabase.sms.getMessageRecordOrNull(messageId.id)
|
||||
}
|
||||
|
||||
if (originalRecord != null) {
|
||||
val originalMessage: ConversationMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(getApplication(), originalRecord, originalRecord.getDisplayBody(getApplication()), 0)
|
||||
emitter.onNext(quotes + listOf(originalMessage))
|
||||
} else {
|
||||
emitter.onNext(quotes)
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
|
||||
return Observable.just(conversationRecipientId)
|
||||
.map { conversationRecipientId ->
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
if (conversationRecipient.groupId.isPresent) {
|
||||
groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get())
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(private val application: Application, private val messageId: MessageId, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(MessageQuotesViewModel(application, messageId, conversationRecipientId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -1535,5 +1536,18 @@ public class AttachmentDatabase extends Database {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final TransformProperties that = (TransformProperties) o;
|
||||
return skipTransform == that.skipTransform && videoTrim == that.videoTrim && videoTrimStartTimeUs == that.videoTrimStartTimeUs && videoTrimEndTimeUs == that.videoTrimEndTimeUs && sentMediaQuality == that.sentMediaQuality;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(skipTransform, videoTrim, videoTrimStartTimeUs, videoTrimEndTimeUs, sentMediaQuality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,19 @@ import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyData
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -35,6 +41,9 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = arrayOf(MembershipTable.CREATE_INDEX)
|
||||
|
||||
const val RECIPIENT_ID = ListTable.RECIPIENT_ID
|
||||
const val DISTRIBUTION_ID = ListTable.DISTRIBUTION_ID
|
||||
const val LIST_TABLE_NAME = ListTable.TABLE_NAME
|
||||
@@ -55,7 +64,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
ListTable.ID to DistributionListId.MY_STORY_ID,
|
||||
ListTable.NAME to DistributionId.MY_STORY.toString(),
|
||||
ListTable.DISTRIBUTION_ID to DistributionId.MY_STORY.toString(),
|
||||
ListTable.RECIPIENT_ID to recipientId
|
||||
ListTable.RECIPIENT_ID to recipientId,
|
||||
ListTable.PRIVACY_MODE to DistributionListPrivacyMode.ALL.serialize()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -71,8 +81,9 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
const val ALLOWS_REPLIES = "allows_replies"
|
||||
const val DELETION_TIMESTAMP = "deletion_timestamp"
|
||||
const val IS_UNKNOWN = "is_unknown"
|
||||
const val PRIVACY_MODE = "privacy_mode"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$NAME TEXT UNIQUE NOT NULL,
|
||||
@@ -80,11 +91,14 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}),
|
||||
$ALLOWS_REPLIES INTEGER DEFAULT 1,
|
||||
$DELETION_TIMESTAMP INTEGER DEFAULT 0,
|
||||
$IS_UNKNOWN INTEGER DEFAULT 0
|
||||
$IS_UNKNOWN INTEGER DEFAULT 0,
|
||||
$PRIVACY_MODE INTEGER DEFAULT ${DistributionListPrivacyMode.ONLY_WITH.serialize()}
|
||||
)
|
||||
"""
|
||||
|
||||
const val IS_NOT_DELETED = "$DELETION_TIMESTAMP == 0"
|
||||
|
||||
val LIST_UI_PROJECTION = arrayOf(ID, NAME, RECIPIENT_ID, ALLOWS_REPLIES, IS_UNKNOWN, PRIVACY_MODE)
|
||||
}
|
||||
|
||||
private object MembershipTable {
|
||||
@@ -93,15 +107,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
const val ID = "_id"
|
||||
const val LIST_ID = "list_id"
|
||||
const val RECIPIENT_ID = "recipient_id"
|
||||
const val PRIVACY_MODE = "privacy_mode"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$LIST_ID INTEGER NOT NULL REFERENCES ${ListTable.TABLE_NAME} (${ListTable.ID}) ON DELETE CASCADE,
|
||||
$RECIPIENT_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}),
|
||||
UNIQUE($LIST_ID, $RECIPIENT_ID) ON CONFLICT IGNORE
|
||||
$PRIVACY_MODE INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
const val CREATE_INDEX = "CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON $TABLE_NAME ($LIST_ID, $RECIPIENT_ID, $PRIVACY_MODE)"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,28 +136,13 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
) == 1
|
||||
}
|
||||
|
||||
fun getAllListsForContactSelectionUi(query: String?, includeMyStory: Boolean): List<DistributionListPartialRecord> {
|
||||
return getAllListsForContactSelectionUiCursor(query, includeMyStory)?.use {
|
||||
val results = mutableListOf<DistributionListPartialRecord>()
|
||||
while (it.moveToNext()) {
|
||||
results.add(
|
||||
DistributionListPartialRecord(
|
||||
id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)),
|
||||
name = CursorUtil.requireString(it, ListTable.NAME),
|
||||
allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES),
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)),
|
||||
isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
results
|
||||
} ?: emptyList()
|
||||
fun setPrivacyMode(distributionListId: DistributionListId, privacyMode: DistributionListPrivacyMode) {
|
||||
val values = contentValuesOf(ListTable.PRIVACY_MODE to privacyMode.serialize())
|
||||
writableDatabase.update(ListTable.TABLE_NAME, values, "${ListTable.ID} = ?", SqlUtil.buildArgs(distributionListId))
|
||||
}
|
||||
|
||||
fun getAllListsForContactSelectionUiCursor(query: String?, includeMyStory: Boolean): Cursor? {
|
||||
val db = readableDatabase
|
||||
val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN)
|
||||
|
||||
val where = when {
|
||||
query.isNullOrEmpty() && includeMyStory -> ListTable.IS_NOT_DELETED
|
||||
@@ -155,24 +157,32 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
else -> SqlUtil.buildArgs(SqlUtil.buildCaseInsensitiveGlobPattern(query), DistributionListId.MY_STORY_ID)
|
||||
}
|
||||
|
||||
return db.query(ListTable.TABLE_NAME, projection, where, whereArgs, null, null, null)
|
||||
return db.query(ListTable.TABLE_NAME, ListTable.LIST_UI_PROJECTION, where, whereArgs, null, null, null)
|
||||
}
|
||||
|
||||
fun getAllListRecipients(): List<RecipientId> {
|
||||
return readableDatabase
|
||||
.select(ListTable.RECIPIENT_ID)
|
||||
.from(ListTable.TABLE_NAME)
|
||||
.run()
|
||||
.readToList { cursor -> RecipientId.from(cursor.requireLong(ListTable.RECIPIENT_ID)) }
|
||||
}
|
||||
|
||||
fun getCustomListsForUi(): List<DistributionListPartialRecord> {
|
||||
val db = readableDatabase
|
||||
val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN)
|
||||
val selection = "${ListTable.ID} != ${DistributionListId.MY_STORY_ID} AND ${ListTable.IS_NOT_DELETED}"
|
||||
|
||||
return db.query(ListTable.TABLE_NAME, projection, selection, null, null, null, null)?.use {
|
||||
return db.query(ListTable.TABLE_NAME, ListTable.LIST_UI_PROJECTION, selection, null, null, null, null)?.use { cursor ->
|
||||
val results = mutableListOf<DistributionListPartialRecord>()
|
||||
while (it.moveToNext()) {
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(
|
||||
DistributionListPartialRecord(
|
||||
id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)),
|
||||
name = CursorUtil.requireString(it, ListTable.NAME),
|
||||
allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES),
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)),
|
||||
isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN)
|
||||
id = DistributionListId.from(CursorUtil.requireLong(cursor, ListTable.ID)),
|
||||
name = CursorUtil.requireString(cursor, ListTable.NAME),
|
||||
allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES),
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, ListTable.RECIPIENT_ID)),
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN),
|
||||
privacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -235,7 +245,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
allowsReplies: Boolean = true,
|
||||
deletionTimestamp: Long = 0L,
|
||||
storageId: ByteArray? = null,
|
||||
isUnknown: Boolean = false
|
||||
isUnknown: Boolean = false,
|
||||
privacyMode: DistributionListPrivacyMode = DistributionListPrivacyMode.ONLY_WITH
|
||||
): DistributionListId? {
|
||||
val db = writableDatabase
|
||||
|
||||
@@ -248,6 +259,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
putNull(ListTable.RECIPIENT_ID)
|
||||
put(ListTable.DELETION_TIMESTAMP, deletionTimestamp)
|
||||
put(ListTable.IS_UNKNOWN, isUnknown)
|
||||
put(ListTable.PRIVACY_MODE, privacyMode.serialize())
|
||||
}
|
||||
|
||||
val id = writableDatabase.insert(ListTable.TABLE_NAME, null, values)
|
||||
@@ -264,7 +276,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
SqlUtil.buildArgs(id)
|
||||
)
|
||||
|
||||
members.forEach { addMemberToList(DistributionListId.from(id), it) }
|
||||
members.forEach { addMemberToList(DistributionListId.from(id), privacyMode, it) }
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
@@ -311,15 +323,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID))
|
||||
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
|
||||
DistributionListRecord(
|
||||
id = id,
|
||||
name = cursor.requireNonNullString(ListTable.NAME),
|
||||
distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)),
|
||||
allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES),
|
||||
rawMembers = getRawMembers(id, privacyMode),
|
||||
members = getMembers(id),
|
||||
deletedAtTimestamp = 0L,
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN)
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN),
|
||||
privacyMode = privacyMode
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -331,15 +346,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID))
|
||||
val privacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
|
||||
DistributionListRecord(
|
||||
id = id,
|
||||
name = cursor.requireNonNullString(ListTable.NAME),
|
||||
distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)),
|
||||
allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES),
|
||||
members = getRawMembers(id),
|
||||
rawMembers = getRawMembers(id, privacyMode),
|
||||
members = emptyList(),
|
||||
deletedAtTimestamp = cursor.requireLong(ListTable.DELETION_TIMESTAMP),
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN)
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN),
|
||||
privacyMode = privacyMode
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -358,28 +376,36 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
}
|
||||
|
||||
fun getMembers(listId: DistributionListId): List<RecipientId> {
|
||||
if (listId == DistributionListId.MY_STORY) {
|
||||
val blockedMembers = getRawMembers(listId).toSet()
|
||||
lateinit var privacyMode: DistributionListPrivacyMode
|
||||
lateinit var rawMembers: List<RecipientId>
|
||||
|
||||
return SignalDatabase.recipients.getSignalContacts(false)?.use {
|
||||
val result = mutableListOf<RecipientId>()
|
||||
while (it.moveToNext()) {
|
||||
val id = RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID))
|
||||
if (!blockedMembers.contains(id)) {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
result
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
return getRawMembers(listId)
|
||||
readableDatabase.withinTransaction {
|
||||
privacyMode = getPrivacyMode(listId)
|
||||
rawMembers = getRawMembers(listId, privacyMode)
|
||||
}
|
||||
|
||||
return when (privacyMode) {
|
||||
DistributionListPrivacyMode.ALL -> {
|
||||
SignalDatabase.recipients
|
||||
.getSignalContacts(false)!!
|
||||
.readToList { it.requireObject(RecipientDatabase.ID, RecipientId.SERIALIZER) }
|
||||
}
|
||||
DistributionListPrivacyMode.ALL_EXCEPT -> {
|
||||
SignalDatabase.recipients
|
||||
.getSignalContacts(false)!!
|
||||
.readToList(
|
||||
predicate = { !rawMembers.contains(it) },
|
||||
mapper = { it.requireObject(RecipientDatabase.ID, RecipientId.SERIALIZER) }
|
||||
)
|
||||
}
|
||||
DistributionListPrivacyMode.ONLY_WITH -> rawMembers
|
||||
}
|
||||
}
|
||||
|
||||
fun getRawMembers(listId: DistributionListId): List<RecipientId> {
|
||||
fun getRawMembers(listId: DistributionListId, privacyMode: DistributionListPrivacyMode): List<RecipientId> {
|
||||
val members = mutableListOf<RecipientId>()
|
||||
|
||||
readableDatabase.query(MembershipTable.TABLE_NAME, null, "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
|
||||
readableDatabase.query(MembershipTable.TABLE_NAME, null, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, privacyMode.serialize()), null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
members.add(RecipientId.from(cursor.requireLong(MembershipTable.RECIPIENT_ID)))
|
||||
}
|
||||
@@ -389,15 +415,35 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
}
|
||||
|
||||
fun getMemberCount(listId: DistributionListId): Int {
|
||||
return if (listId == DistributionListId.MY_STORY) {
|
||||
SignalDatabase.recipients.getSignalContacts(false)?.count?.let { it - getRawMemberCount(listId) } ?: 0
|
||||
} else {
|
||||
getRawMemberCount(listId)
|
||||
}
|
||||
return getPrivacyData(listId).memberCount
|
||||
}
|
||||
|
||||
fun getRawMemberCount(listId: DistributionListId): Int {
|
||||
readableDatabase.query(MembershipTable.TABLE_NAME, SqlUtil.buildArgs("COUNT(*)"), "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
|
||||
fun getPrivacyData(listId: DistributionListId): DistributionListPrivacyData {
|
||||
lateinit var privacyMode: DistributionListPrivacyMode
|
||||
var rawMemberCount = 0
|
||||
var totalContactCount = 0
|
||||
|
||||
readableDatabase.withinTransaction {
|
||||
privacyMode = getPrivacyMode(listId)
|
||||
rawMemberCount = getRawMemberCount(listId, privacyMode)
|
||||
totalContactCount = SignalDatabase.recipients.getSignalContactsCount(false)
|
||||
}
|
||||
|
||||
val memberCount = when (privacyMode) {
|
||||
DistributionListPrivacyMode.ALL -> totalContactCount
|
||||
DistributionListPrivacyMode.ALL_EXCEPT -> totalContactCount - rawMemberCount
|
||||
DistributionListPrivacyMode.ONLY_WITH -> rawMemberCount
|
||||
}
|
||||
|
||||
return DistributionListPrivacyData(
|
||||
privacyMode = privacyMode,
|
||||
rawMemberCount = rawMemberCount,
|
||||
memberCount = memberCount
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRawMemberCount(listId: DistributionListId, privacyMode: DistributionListPrivacyMode): Int {
|
||||
readableDatabase.query(MembershipTable.TABLE_NAME, SqlUtil.buildArgs("COUNT(*)"), "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, privacyMode.serialize()), null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
@@ -406,24 +452,46 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberFromList(listId: DistributionListId, member: RecipientId) {
|
||||
writableDatabase.delete(MembershipTable.TABLE_NAME, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(listId, member))
|
||||
private fun getPrivacyMode(listId: DistributionListId): DistributionListPrivacyMode {
|
||||
return readableDatabase
|
||||
.select(ListTable.PRIVACY_MODE)
|
||||
.from(ListTable.TABLE_NAME)
|
||||
.where("${ListTable.ID} = ?", listId.serialize())
|
||||
.run()
|
||||
.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
} else {
|
||||
DistributionListPrivacyMode.ONLY_WITH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addMemberToList(listId: DistributionListId, member: RecipientId) {
|
||||
fun removeMemberFromList(listId: DistributionListId, privacyMode: DistributionListPrivacyMode, member: RecipientId) {
|
||||
writableDatabase.delete(MembershipTable.TABLE_NAME, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, member, privacyMode.serialize()))
|
||||
}
|
||||
|
||||
fun addMemberToList(listId: DistributionListId, privacyMode: DistributionListPrivacyMode, member: RecipientId) {
|
||||
val values = ContentValues().apply {
|
||||
put(MembershipTable.LIST_ID, listId.serialize())
|
||||
put(MembershipTable.RECIPIENT_ID, member.serialize())
|
||||
put(MembershipTable.PRIVACY_MODE, privacyMode.serialize())
|
||||
}
|
||||
|
||||
writableDatabase.insert(MembershipTable.TABLE_NAME, null, values)
|
||||
}
|
||||
|
||||
fun removeAllMembers(listId: DistributionListId) {
|
||||
writableDatabase
|
||||
.delete(MembershipTable.TABLE_NAME)
|
||||
.where("${MembershipTable.LIST_ID} = ?", listId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
fun remapRecipient(oldId: RecipientId, newId: RecipientId) {
|
||||
val values = ContentValues().apply {
|
||||
put(MembershipTable.RECIPIENT_ID, newId.serialize())
|
||||
}
|
||||
|
||||
writableDatabase.update(MembershipTable.TABLE_NAME, values, "${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(oldId))
|
||||
}
|
||||
|
||||
@@ -487,12 +555,19 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
throw AssertionError("Should never try to insert My Story")
|
||||
}
|
||||
|
||||
val privacyMode: DistributionListPrivacyMode = when {
|
||||
insert.isBlockList && insert.recipients.isEmpty() -> DistributionListPrivacyMode.ALL
|
||||
insert.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT
|
||||
else -> DistributionListPrivacyMode.ONLY_WITH
|
||||
}
|
||||
|
||||
createList(
|
||||
name = insert.name,
|
||||
members = insert.recipients.map(RecipientId::from),
|
||||
distributionId = distributionId,
|
||||
allowsReplies = insert.allowsReplies(),
|
||||
deletionTimestamp = insert.deletedAtTimestamp,
|
||||
privacyMode = privacyMode,
|
||||
storageId = insert.id.raw
|
||||
)
|
||||
}
|
||||
@@ -526,12 +601,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
return
|
||||
}
|
||||
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
val privacyMode: DistributionListPrivacyMode = when {
|
||||
update.new.isBlockList && update.new.recipients.isEmpty() -> DistributionListPrivacyMode.ALL
|
||||
update.new.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT
|
||||
else -> DistributionListPrivacyMode.ONLY_WITH
|
||||
}
|
||||
|
||||
writableDatabase.withinTransaction {
|
||||
val listTableValues = contentValuesOf(
|
||||
ListTable.ALLOWS_REPLIES to update.new.allowsReplies(),
|
||||
ListTable.NAME to update.new.name,
|
||||
ListTable.IS_UNKNOWN to false
|
||||
ListTable.IS_UNKNOWN to false,
|
||||
ListTable.PRIVACY_MODE to privacyMode.serialize()
|
||||
)
|
||||
|
||||
writableDatabase.update(
|
||||
@@ -541,22 +622,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
|
||||
SqlUtil.buildArgs(distributionId.toString())
|
||||
)
|
||||
|
||||
val currentlyInDistributionList = getRawMembers(distributionListId).toSet()
|
||||
val currentlyInDistributionList = getRawMembers(distributionListId, privacyMode).toSet()
|
||||
val shouldBeInDistributionList = update.new.recipients.map(RecipientId::from).toSet()
|
||||
val toRemove = currentlyInDistributionList - shouldBeInDistributionList
|
||||
val toAdd = shouldBeInDistributionList - currentlyInDistributionList
|
||||
|
||||
toRemove.forEach {
|
||||
removeMemberFromList(distributionListId, it)
|
||||
removeMemberFromList(distributionListId, privacyMode, it)
|
||||
}
|
||||
|
||||
toAdd.forEach {
|
||||
addMemberToList(distributionListId, it)
|
||||
addMemberToList(distributionListId, privacyMode, it)
|
||||
}
|
||||
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1418,8 +1418,12 @@ public class GroupDatabase extends Database {
|
||||
}
|
||||
|
||||
public void markDisplayAsStory(@NonNull GroupId groupId) {
|
||||
markDisplayAsStory(groupId, true);
|
||||
}
|
||||
|
||||
public void markDisplayAsStory(@NonNull GroupId groupId, boolean displayAsStory) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(DISPLAY_AS_STORY, true);
|
||||
contentValues.put(DISPLAY_AS_STORY, displayAsStory);
|
||||
|
||||
getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId.toString()));
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ public class MmsDatabase extends MessageDatabase {
|
||||
"CREATE INDEX IF NOT EXISTS mms_is_story_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON " + TABLE_NAME + "(" + QUOTE_ID + ", " + QUOTE_AUTHOR + ");"
|
||||
};
|
||||
|
||||
private static final String[] MMS_PROJECTION = new String[] {
|
||||
@@ -644,11 +645,12 @@ public class MmsDatabase extends MessageDatabase {
|
||||
|
||||
@Override
|
||||
public @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit) {
|
||||
final String query = IS_STORY_CLAUSE +
|
||||
" AND NOT (" + getOutgoingTypeClause() + ") " +
|
||||
" AND " + RECIPIENT_ID + " = ?" +
|
||||
" AND " + VIEWED_RECEIPT_COUNT + " = ?";
|
||||
final String[] args = SqlUtil.buildArgs(recipientId, 0);
|
||||
final long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId);
|
||||
final String query = IS_STORY_CLAUSE +
|
||||
" AND NOT (" + getOutgoingTypeClause() + ") " +
|
||||
" AND " + THREAD_ID_WHERE +
|
||||
" AND " + VIEWED_RECEIPT_COUNT + " = ?";
|
||||
final String[] args = SqlUtil.buildArgs(threadId, 0);
|
||||
|
||||
return new Reader(rawQuery(query, args, false, limit));
|
||||
}
|
||||
@@ -2180,7 +2182,11 @@ public class MmsDatabase extends MessageDatabase {
|
||||
SignalDatabase.threads().updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId);
|
||||
|
||||
if (!message.getStoryType().isStory()) {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true));
|
||||
if (message.getOutgoingQuote() == null) {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true));
|
||||
} else {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId);
|
||||
}
|
||||
} else {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.notifications.v2.MessageNotifierV2;
|
||||
@@ -273,6 +274,45 @@ public class MmsSmsDatabase extends Database {
|
||||
return queryTables(PROJECTION, selection, order, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of messages that quote the target message
|
||||
*/
|
||||
public int getQuotedCount(@NonNull MessageRecord messageRecord) {
|
||||
RecipientId author = messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId();
|
||||
long timestamp = messageRecord.getDateSent();
|
||||
|
||||
String where = MmsDatabase.QUOTE_ID + " = ? AND " + MmsDatabase.QUOTE_AUTHOR + " = ?";
|
||||
String[] whereArgs = SqlUtil.buildArgs(timestamp, author);
|
||||
|
||||
try (Cursor cursor = getReadableDatabase().query(MmsDatabase.TABLE_NAME, COUNT, where, whereArgs, null, null, null)) {
|
||||
return cursor.moveToFirst() ? cursor.getInt(0) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public List<MessageRecord> getAllMessagesThatQuote(@NonNull MessageId id) {
|
||||
MessageRecord targetMessage;
|
||||
try {
|
||||
targetMessage = id.isMms() ? SignalDatabase.mms().getMessageRecord(id.getId()) : SignalDatabase.sms().getMessageRecord(id.getId());
|
||||
} catch (NoSuchMessageException e) {
|
||||
throw new IllegalArgumentException("Invalid message ID!");
|
||||
}
|
||||
|
||||
RecipientId author = targetMessage.isOutgoing() ? Recipient.self().getId() : targetMessage.getRecipient().getId();
|
||||
String query = MmsDatabase.QUOTE_ID + " = " + targetMessage.getDateSent() + " AND " + MmsDatabase.QUOTE_AUTHOR + " = " + author.serialize();
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
|
||||
List<MessageRecord> records = new ArrayList<>();
|
||||
|
||||
try (Reader reader = new Reader(queryTables(PROJECTION, query, order, null, true))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private @NonNull String getStickyWherePartForParentStoryId(@Nullable Long parentStoryId) {
|
||||
if (parentStoryId == null) {
|
||||
return " AND " + MmsDatabase.PARENT_STORY_ID + " <= 0";
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
|
||||
/**
|
||||
* Encapsulates data around processing a tuple of user data into a user entry in [RecipientDatabase].
|
||||
* Also lets you apply a list of [PnpOperation]s to get what the resulting dataset would be.
|
||||
*/
|
||||
data class PnpDataSet(
|
||||
val e164: String?,
|
||||
val pni: PNI?,
|
||||
val aci: ACI?,
|
||||
val byE164: RecipientId?,
|
||||
val byPniSid: RecipientId?,
|
||||
val byPniOnly: RecipientId?,
|
||||
val byAciSid: RecipientId?,
|
||||
val e164Record: RecipientRecord? = null,
|
||||
val pniSidRecord: RecipientRecord? = null,
|
||||
val aciSidRecord: RecipientRecord? = null
|
||||
) {
|
||||
|
||||
/**
|
||||
* @return The common id if all non-null ids are equal, or null if all are null or at least one non-null pair doesn't match.
|
||||
*/
|
||||
val commonId: RecipientId? = findCommonId(listOf(byE164, byPniSid, byPniOnly, byAciSid))
|
||||
|
||||
fun MutableSet<RecipientRecord>.replace(recipientId: RecipientId, update: (RecipientRecord) -> RecipientRecord) {
|
||||
val toUpdate = this.first { it.id == recipientId }
|
||||
this -= toUpdate
|
||||
this += update(toUpdate)
|
||||
}
|
||||
/**
|
||||
* Applies the set of operations and returns the resulting dataset.
|
||||
* Important: This only occurs _in memory_. You must still apply the operations to disk to persist them.
|
||||
*/
|
||||
fun perform(operations: List<PnpOperation>): PnpDataSet {
|
||||
if (operations.isEmpty()) {
|
||||
return this
|
||||
}
|
||||
|
||||
val records: MutableSet<RecipientRecord> = listOfNotNull(e164Record, pniSidRecord, aciSidRecord).toMutableSet()
|
||||
|
||||
for (operation in operations) {
|
||||
@Exhaustive
|
||||
when (operation) {
|
||||
is PnpOperation.Update -> {
|
||||
records.replace(operation.recipientId) { record ->
|
||||
record.copy(
|
||||
e164 = operation.e164,
|
||||
pni = operation.pni,
|
||||
serviceId = operation.aci ?: operation.pni
|
||||
)
|
||||
}
|
||||
}
|
||||
is PnpOperation.RemoveE164 -> {
|
||||
records.replace(operation.recipientId) { it.copy(e164 = null) }
|
||||
}
|
||||
is PnpOperation.RemovePni -> {
|
||||
records.replace(operation.recipientId) { record ->
|
||||
record.copy(
|
||||
pni = null,
|
||||
serviceId = if (record.sidIsPni()) {
|
||||
null
|
||||
} else {
|
||||
record.serviceId
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is PnpOperation.SetAci -> {
|
||||
records.replace(operation.recipientId) { it.copy(serviceId = operation.aci) }
|
||||
}
|
||||
is PnpOperation.SetE164 -> {
|
||||
records.replace(operation.recipientId) { it.copy(e164 = operation.e164) }
|
||||
}
|
||||
is PnpOperation.SetPni -> {
|
||||
records.replace(operation.recipientId) { record ->
|
||||
record.copy(
|
||||
pni = operation.pni,
|
||||
serviceId = if (record.sidIsPni()) {
|
||||
operation.pni
|
||||
} else {
|
||||
record.serviceId ?: operation.pni
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is PnpOperation.Merge -> {
|
||||
val primary: RecipientRecord = records.first { it.id == operation.primaryId }
|
||||
val secondary: RecipientRecord = records.first { it.id == operation.secondaryId }
|
||||
|
||||
records.replace(primary.id) { _ ->
|
||||
primary.copy(
|
||||
e164 = primary.e164 ?: secondary.e164,
|
||||
pni = primary.pni ?: secondary.pni,
|
||||
serviceId = primary.serviceId ?: secondary.serviceId
|
||||
)
|
||||
}
|
||||
|
||||
records -= secondary
|
||||
}
|
||||
is PnpOperation.SessionSwitchoverInsert -> Unit
|
||||
is PnpOperation.ChangeNumberInsert -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val newE164Record = if (e164 != null) records.firstOrNull { it.e164 == e164 } else null
|
||||
val newPniSidRecord = if (pni != null) records.firstOrNull { it.serviceId == pni } else null
|
||||
val newAciSidRecord = if (aci != null) records.firstOrNull { it.serviceId == aci } else null
|
||||
|
||||
return PnpDataSet(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
aci = aci,
|
||||
byE164 = newE164Record?.id,
|
||||
byPniSid = newPniSidRecord?.id,
|
||||
byPniOnly = byPniOnly,
|
||||
byAciSid = newAciSidRecord?.id,
|
||||
e164Record = newE164Record,
|
||||
pniSidRecord = newPniSidRecord,
|
||||
aciSidRecord = newAciSidRecord
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun findCommonId(ids: List<RecipientId?>): RecipientId? {
|
||||
val nonNull = ids.filterNotNull()
|
||||
|
||||
return when {
|
||||
nonNull.isEmpty() -> null
|
||||
nonNull.all { it == nonNull[0] } -> nonNull[0]
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a set of actions that need to be applied to incorporate a tuple of user data
|
||||
* into [RecipientDatabase].
|
||||
*/
|
||||
data class PnpChangeSet(
|
||||
val id: PnpIdResolver,
|
||||
val operations: List<PnpOperation> = emptyList()
|
||||
)
|
||||
|
||||
sealed class PnpIdResolver {
|
||||
data class PnpNoopId(
|
||||
val recipientId: RecipientId
|
||||
) : PnpIdResolver()
|
||||
|
||||
data class PnpInsert(
|
||||
val e164: String?,
|
||||
val pni: PNI?,
|
||||
val aci: ACI?
|
||||
) : PnpIdResolver()
|
||||
}
|
||||
|
||||
/**
|
||||
* An operation that needs to be performed on the [RecipientDatabase] as part of merging in new user data.
|
||||
* Lets us describe various situations as a series of operations, making code clearer and tests easier.
|
||||
*/
|
||||
sealed class PnpOperation {
|
||||
data class Update(
|
||||
val recipientId: RecipientId,
|
||||
val e164: String?,
|
||||
val pni: PNI?,
|
||||
val aci: ACI?
|
||||
) : PnpOperation()
|
||||
|
||||
data class RemoveE164(
|
||||
val recipientId: RecipientId
|
||||
) : PnpOperation()
|
||||
|
||||
data class RemovePni(
|
||||
val recipientId: RecipientId
|
||||
) : PnpOperation()
|
||||
|
||||
data class SetE164(
|
||||
val recipientId: RecipientId,
|
||||
val e164: String
|
||||
) : PnpOperation()
|
||||
|
||||
data class SetPni(
|
||||
val recipientId: RecipientId,
|
||||
val pni: PNI
|
||||
) : PnpOperation()
|
||||
|
||||
data class SetAci(
|
||||
val recipientId: RecipientId,
|
||||
val aci: ACI
|
||||
) : PnpOperation()
|
||||
|
||||
/**
|
||||
* Merge two rows into one. Prefer data in the primary row when there's conflicts. Delete the secondary row afterwards.
|
||||
*/
|
||||
data class Merge(
|
||||
val primaryId: RecipientId,
|
||||
val secondaryId: RecipientId
|
||||
) : PnpOperation()
|
||||
|
||||
data class SessionSwitchoverInsert(
|
||||
val recipientId: RecipientId
|
||||
) : PnpOperation()
|
||||
|
||||
data class ChangeNumberInsert(
|
||||
val recipientId: RecipientId,
|
||||
val oldE164: String,
|
||||
val newE164: String
|
||||
) : PnpOperation()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import net.zetetic.database.sqlcipher.SQLiteConstraintException
|
||||
@@ -27,6 +28,7 @@ import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
@@ -53,7 +55,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.identities
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.notificationProfiles
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSuccessfulTransaction
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends
|
||||
@@ -104,6 +105,7 @@ import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record
|
||||
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.util.Arrays
|
||||
@@ -821,6 +823,15 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
}
|
||||
}
|
||||
|
||||
fun markNeedsSync(recipientIds: Collection<RecipientId>) {
|
||||
writableDatabase
|
||||
.withinTransaction {
|
||||
for (recipientId in recipientIds) {
|
||||
markNeedsSync(recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markNeedsSync(recipientId: RecipientId) {
|
||||
rotateStorageId(recipientId)
|
||||
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(recipientId)
|
||||
@@ -2169,43 +2180,388 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
|
||||
@VisibleForTesting
|
||||
fun processCdsV2Result(e164: String, pni: PNI, aci: ACI?): RecipientId {
|
||||
val byE164: RecipientId? = getByE164(e164).orElse(null)
|
||||
val byPni: RecipientId? = getByServiceId(pni).orElse(null)
|
||||
val byPniOnly: RecipientId? = getByPni(pni).orElse(null)
|
||||
val byAci: RecipientId? = aci?.let { getByServiceId(it).orElse(null) }
|
||||
val result = processPnpTupleToChangeSet(e164, pni, aci, pniVerified = false)
|
||||
|
||||
val commonId: RecipientId? = listOf(byE164, byPni, byPniOnly, byAci).commonId()
|
||||
val allRequiredDbFields: List<RecipientId?> = if (aci != null) listOf(byE164, byAci, byPniOnly) else listOf(byE164, byPni, byPniOnly)
|
||||
val allRequiredDbFieldPopulated: Boolean = allRequiredDbFields.all { it != null }
|
||||
|
||||
// All ID's agree and the database is up-to-date
|
||||
if (commonId != null && allRequiredDbFieldPopulated) {
|
||||
return commonId
|
||||
val id: RecipientId = when (result.id) {
|
||||
is PnpIdResolver.PnpNoopId -> {
|
||||
result.id.recipientId
|
||||
}
|
||||
is PnpIdResolver.PnpInsert -> {
|
||||
val id: Long = writableDatabase.insert(TABLE_NAME, null, buildContentValuesForCdsInsert(result.id.e164, result.id.pni, result.id.aci))
|
||||
RecipientId.from(id)
|
||||
}
|
||||
}
|
||||
|
||||
// All ID's agree but we need to update the database
|
||||
if (commonId != null && !allRequiredDbFieldPopulated) {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
PHONE to e164,
|
||||
SERVICE_ID to (aci ?: pni).toString(),
|
||||
PNI_COLUMN to pni.toString(),
|
||||
REGISTERED to RegisteredState.REGISTERED.id,
|
||||
STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey())
|
||||
for (operation in result.operations) {
|
||||
@Exhaustive
|
||||
when (operation) {
|
||||
is PnpOperation.Update -> {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(
|
||||
PHONE to operation.e164,
|
||||
SERVICE_ID to (operation.aci ?: operation.pni).toString(),
|
||||
PNI_COLUMN to operation.pni.toString()
|
||||
)
|
||||
.where("$ID = ?", operation.recipientId)
|
||||
.run()
|
||||
}
|
||||
is PnpOperation.Merge -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.SessionSwitchoverInsert -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.ChangeNumberInsert -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.RemoveE164 -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.RemovePni -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.SetAci -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.SetE164 -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
is PnpOperation.SetPni -> {
|
||||
// TODO [pnp]
|
||||
error("Not yet implemented")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a tuple of (e164, pni, aci) and converts that into a list of changes that would need to be made to
|
||||
* merge that data into our database.
|
||||
*
|
||||
* The database will be read, but not written to, during this function.
|
||||
* It is assumed that we are in a transaction.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
fun processPnpTupleToChangeSet(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean): PnpChangeSet {
|
||||
Preconditions.checkArgument(e164 != null || pni != null || aci != null, "Must provide at least one field!")
|
||||
Preconditions.checkArgument(pni == null || e164 != null, "If a PNI is provided, you must also provide an E164!")
|
||||
|
||||
val partialData = PnpDataSet(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
aci = aci,
|
||||
byE164 = e164?.let { getByE164(it).orElse(null) },
|
||||
byPniSid = pni?.let { getByServiceId(it).orElse(null) },
|
||||
byPniOnly = pni?.let { getByPni(it).orElse(null) },
|
||||
byAciSid = aci?.let { getByServiceId(it).orElse(null) }
|
||||
)
|
||||
|
||||
val allRequiredDbFields: List<RecipientId?> = if (aci != null) {
|
||||
listOf(partialData.byE164, partialData.byAciSid, partialData.byPniOnly)
|
||||
} else {
|
||||
listOf(partialData.byE164, partialData.byPniSid, partialData.byPniOnly)
|
||||
}
|
||||
|
||||
val allRequiredDbFieldPopulated: Boolean = allRequiredDbFields.all { it != null }
|
||||
|
||||
// All IDs agree and the database is up-to-date
|
||||
if (partialData.commonId != null && allRequiredDbFieldPopulated) {
|
||||
return PnpChangeSet(id = PnpIdResolver.PnpNoopId(partialData.commonId))
|
||||
}
|
||||
|
||||
// All ID's agree, but we need to update the database
|
||||
if (partialData.commonId != null && !allRequiredDbFieldPopulated) {
|
||||
val record: RecipientRecord = getRecord(partialData.commonId)
|
||||
|
||||
val operations: MutableList<PnpOperation> = mutableListOf()
|
||||
|
||||
if (e164 != null && record.e164 != e164) {
|
||||
operations += PnpOperation.SetE164(
|
||||
recipientId = partialData.commonId,
|
||||
e164 = e164
|
||||
)
|
||||
.where("$ID = ?", commonId)
|
||||
.run()
|
||||
return commonId
|
||||
}
|
||||
|
||||
if (pni != null && record.pni != pni) {
|
||||
operations += PnpOperation.SetPni(
|
||||
recipientId = partialData.commonId,
|
||||
pni = pni
|
||||
)
|
||||
}
|
||||
|
||||
if (aci != null && record.serviceId != aci) {
|
||||
operations += PnpOperation.SetAci(
|
||||
recipientId = partialData.commonId,
|
||||
aci = aci
|
||||
)
|
||||
}
|
||||
|
||||
if (e164 != null && record.e164 != null && record.e164 != e164) {
|
||||
operations += PnpOperation.ChangeNumberInsert(
|
||||
recipientId = partialData.commonId,
|
||||
oldE164 = record.e164,
|
||||
newE164 = e164
|
||||
)
|
||||
}
|
||||
|
||||
val newServiceId: ServiceId? = aci ?: pni ?: record.serviceId
|
||||
|
||||
if (!pniVerified && record.serviceId != null && record.serviceId != newServiceId && sessions.hasAnySessionFor(record.serviceId.toString())) {
|
||||
operations += PnpOperation.SessionSwitchoverInsert(partialData.commonId)
|
||||
}
|
||||
|
||||
return PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(partialData.commonId),
|
||||
operations = operations
|
||||
)
|
||||
}
|
||||
|
||||
// Nothing matches
|
||||
if (byE164 == null && byPni == null && byAci == null) {
|
||||
val id: Long = writableDatabase.insert(TABLE_NAME, null, buildContentValuesForCdsInsert(e164, pni, aci))
|
||||
return RecipientId.from(id)
|
||||
if (partialData.byE164 == null && partialData.byPniSid == null && partialData.byAciSid == null) {
|
||||
return PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
aci = aci
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
throw NotImplementedError("Handle cases where IDs map to different individuals")
|
||||
// TODO pni only record?
|
||||
|
||||
// At this point, we know that records have been found for at least two of the fields,
|
||||
// and that there are at least two unique IDs among the records.
|
||||
//
|
||||
// In other words, *some* sort of merging of data must now occur.
|
||||
// It may be that some data just gets shuffled around, or it may be that
|
||||
// two or more records get merged into one record, with the others being deleted.
|
||||
|
||||
val fullData = partialData.copy(
|
||||
e164Record = partialData.byE164?.let { getRecord(it) },
|
||||
pniSidRecord = partialData.byPniSid?.let { getRecord(it) },
|
||||
aciSidRecord = partialData.byAciSid?.let { getRecord(it) },
|
||||
)
|
||||
|
||||
Preconditions.checkState(fullData.commonId == null)
|
||||
Preconditions.checkState(listOfNotNull(fullData.byE164, fullData.byPniSid, fullData.byPniOnly, fullData.byAciSid).size >= 2)
|
||||
|
||||
val operations: MutableList<PnpOperation> = mutableListOf()
|
||||
|
||||
operations += processPossibleE164PniSidMerge(pni, pniVerified, fullData)
|
||||
operations += processPossiblePniSidAciSidMerge(e164, pni, aci, fullData.perform(operations))
|
||||
operations += processPossibleE164AciSidMerge(e164, pni, aci, fullData.perform(operations))
|
||||
|
||||
val finalData: PnpDataSet = fullData.perform(operations)
|
||||
val primaryId: RecipientId = listOfNotNull(finalData.byAciSid, finalData.byE164, finalData.byPniSid).first()
|
||||
|
||||
if (finalData.byAciSid == null && aci != null) {
|
||||
operations += PnpOperation.SetAci(
|
||||
recipientId = primaryId,
|
||||
aci = aci
|
||||
)
|
||||
}
|
||||
|
||||
if (finalData.byE164 == null && e164 != null) {
|
||||
operations += PnpOperation.SetE164(
|
||||
recipientId = primaryId,
|
||||
e164 = e164
|
||||
)
|
||||
}
|
||||
|
||||
if (finalData.byPniSid == null && finalData.byPniOnly == null && pni != null) {
|
||||
operations += PnpOperation.SetPni(
|
||||
recipientId = primaryId,
|
||||
pni = pni
|
||||
)
|
||||
}
|
||||
|
||||
return PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(primaryId),
|
||||
operations = operations
|
||||
)
|
||||
}
|
||||
|
||||
private fun processPossibleE164PniSidMerge(pni: PNI?, pniVerified: Boolean, data: PnpDataSet): List<PnpOperation> {
|
||||
if (pni == null || data.byE164 == null || data.byPniSid == null || data.e164Record == null || data.pniSidRecord == null || data.e164Record.id == data.pniSidRecord.id) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// We have found records for both the E164 and PNI, and they're different
|
||||
|
||||
val operations: MutableList<PnpOperation> = mutableListOf()
|
||||
|
||||
// The PNI record only has a single identifier. We know we must merge.
|
||||
if (data.pniSidRecord.sidOnly(pni)) {
|
||||
if (data.e164Record.pni != null) {
|
||||
operations += PnpOperation.RemovePni(data.byE164)
|
||||
}
|
||||
|
||||
operations += PnpOperation.Merge(
|
||||
primaryId = data.byE164,
|
||||
secondaryId = data.byPniSid
|
||||
)
|
||||
|
||||
// TODO: Possible session switchover?
|
||||
} else {
|
||||
Preconditions.checkState(!data.pniSidRecord.pniAndAci())
|
||||
Preconditions.checkState(data.pniSidRecord.e164 != null)
|
||||
|
||||
operations += PnpOperation.RemovePni(data.byPniSid)
|
||||
operations += PnpOperation.SetPni(
|
||||
recipientId = data.byE164,
|
||||
pni = pni
|
||||
)
|
||||
|
||||
if (!pniVerified && sessions.hasAnySessionFor(data.pniSidRecord.serviceId.toString())) {
|
||||
operations += PnpOperation.SessionSwitchoverInsert(data.byPniSid)
|
||||
}
|
||||
|
||||
if (!pniVerified && data.e164Record.serviceId != null && data.e164Record.sidIsPni() && sessions.hasAnySessionFor(data.e164Record.serviceId.toString())) {
|
||||
operations += PnpOperation.SessionSwitchoverInsert(data.byE164)
|
||||
}
|
||||
}
|
||||
|
||||
return operations
|
||||
}
|
||||
|
||||
private fun processPossiblePniSidAciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List<PnpOperation> {
|
||||
if (pni == null || aci == null || data.byPniSid == null || data.byAciSid == null || data.pniSidRecord == null || data.aciSidRecord == null || data.pniSidRecord.id == data.aciSidRecord.id) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// We have found records for both the PNI and ACI, and they're different
|
||||
|
||||
val operations: MutableList<PnpOperation> = mutableListOf()
|
||||
|
||||
// The PNI record only has a single identifier. We know we must merge.
|
||||
if (data.pniSidRecord.sidOnly(pni)) {
|
||||
if (data.aciSidRecord.pni != null) {
|
||||
operations += PnpOperation.RemovePni(data.byAciSid)
|
||||
}
|
||||
|
||||
operations += PnpOperation.Merge(
|
||||
primaryId = data.byAciSid,
|
||||
secondaryId = data.byPniSid
|
||||
)
|
||||
} else if (data.pniSidRecord.e164 == e164) {
|
||||
// The PNI record also has the E164 on it. We're going to be stealing both fields,
|
||||
// so this is basically a merge with a little bit of extra prep.
|
||||
|
||||
if (data.aciSidRecord.pni != null) {
|
||||
operations += PnpOperation.RemovePni(data.byAciSid)
|
||||
}
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.RemoveE164(data.byAciSid)
|
||||
}
|
||||
|
||||
operations += PnpOperation.Merge(
|
||||
primaryId = data.byAciSid,
|
||||
secondaryId = data.byPniSid
|
||||
)
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.ChangeNumberInsert(
|
||||
recipientId = data.byAciSid,
|
||||
oldE164 = data.aciSidRecord.e164,
|
||||
newE164 = e164!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Preconditions.checkState(data.pniSidRecord.e164 != null && data.pniSidRecord.e164 != e164)
|
||||
|
||||
operations += PnpOperation.RemovePni(data.byPniSid)
|
||||
|
||||
operations += PnpOperation.Update(
|
||||
recipientId = data.byAciSid,
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
aci = ACI.from(data.aciSidRecord.serviceId)
|
||||
)
|
||||
}
|
||||
|
||||
return operations
|
||||
}
|
||||
|
||||
private fun processPossibleE164AciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List<PnpOperation> {
|
||||
if (e164 == null || aci == null || data.byE164 == null || data.byAciSid == null || data.e164Record == null || data.aciSidRecord == null || data.e164Record.id == data.aciSidRecord.id) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// We have found records for both the E164 and ACI, and they're different
|
||||
|
||||
val operations: MutableList<PnpOperation> = mutableListOf()
|
||||
|
||||
// The PNI record only has a single identifier. We know we must merge.
|
||||
if (data.e164Record.e164Only()) {
|
||||
// TODO high trust
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.RemoveE164(data.byAciSid)
|
||||
}
|
||||
|
||||
operations += PnpOperation.Merge(
|
||||
primaryId = data.byAciSid,
|
||||
secondaryId = data.byE164
|
||||
)
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.ChangeNumberInsert(
|
||||
recipientId = data.byAciSid,
|
||||
oldE164 = data.aciSidRecord.e164,
|
||||
newE164 = e164
|
||||
)
|
||||
}
|
||||
} else if (data.e164Record.pni != null && data.e164Record.pni == pni) {
|
||||
// The E164 record also has the PNI on it. We're going to be stealing both fields,
|
||||
// so this is basically a merge with a little bit of extra prep.
|
||||
if (data.aciSidRecord.pni != null) {
|
||||
operations += PnpOperation.RemovePni(data.byAciSid)
|
||||
}
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.RemoveE164(data.byAciSid)
|
||||
}
|
||||
|
||||
operations += PnpOperation.Merge(
|
||||
primaryId = data.byAciSid,
|
||||
secondaryId = data.byE164
|
||||
)
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.ChangeNumberInsert(
|
||||
recipientId = data.byAciSid,
|
||||
oldE164 = data.aciSidRecord.e164,
|
||||
newE164 = e164!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
operations += PnpOperation.RemoveE164(data.byE164)
|
||||
|
||||
operations += PnpOperation.SetE164(
|
||||
recipientId = data.byAciSid,
|
||||
e164 = e164
|
||||
)
|
||||
|
||||
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
|
||||
operations += PnpOperation.ChangeNumberInsert(
|
||||
recipientId = data.byAciSid,
|
||||
oldE164 = data.aciSidRecord.e164,
|
||||
newE164 = e164
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return operations
|
||||
}
|
||||
|
||||
fun getUninvitedRecipientsForInsights(): List<RecipientId> {
|
||||
@@ -2301,6 +2657,14 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
}
|
||||
|
||||
fun getSignalContacts(includeSelf: Boolean): Cursor? {
|
||||
return getSignalContacts(includeSelf, "$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $USERNAME, $PHONE")
|
||||
}
|
||||
|
||||
fun getSignalContactsCount(includeSelf: Boolean): Int {
|
||||
return getSignalContacts(includeSelf)?.count ?: 0
|
||||
}
|
||||
|
||||
fun getSignalContacts(includeSelf: Boolean, orderBy: String? = null): Cursor? {
|
||||
val searchSelection = ContactSearchSelection.Builder()
|
||||
.withRegistered(true)
|
||||
.withGroups(false)
|
||||
@@ -2308,7 +2672,6 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
.build()
|
||||
val selection = searchSelection.where
|
||||
val args = searchSelection.args
|
||||
val orderBy = "$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $USERNAME, $PHONE"
|
||||
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy)
|
||||
}
|
||||
|
||||
@@ -2922,16 +3285,18 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
return values
|
||||
}
|
||||
|
||||
private fun buildContentValuesForCdsInsert(e164: String, pni: PNI, aci: ACI?): ContentValues {
|
||||
val serviceId: ServiceId = aci ?: pni
|
||||
return ContentValues().apply {
|
||||
put(PHONE, e164)
|
||||
put(SERVICE_ID, serviceId.toString())
|
||||
put(PNI_COLUMN, pni.toString())
|
||||
put(REGISTERED, RegisteredState.REGISTERED.id)
|
||||
put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()))
|
||||
put(AVATAR_COLOR, AvatarColor.random().serialize())
|
||||
}
|
||||
private fun buildContentValuesForCdsInsert(e164: String?, pni: PNI?, aci: ACI?): ContentValues {
|
||||
Preconditions.checkArgument(pni != null || aci != null, "Must provide a serviceId!")
|
||||
|
||||
val serviceId: ServiceId = aci ?: pni!!
|
||||
return contentValuesOf(
|
||||
PHONE to e164,
|
||||
SERVICE_ID to serviceId.toString(),
|
||||
PNI_COLUMN to pni.toString(),
|
||||
REGISTERED to RegisteredState.REGISTERED.id,
|
||||
STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey()),
|
||||
AVATAR_COLOR to AvatarColor.random().serialize()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getValuesForStorageContact(contact: SignalContactRecord, isInsert: Boolean): ContentValues {
|
||||
@@ -3009,22 +3374,6 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The common id if all non-null ids are equal, or null if all are null or at least one non-null pair doesn't match.
|
||||
*/
|
||||
private fun Collection<RecipientId?>.commonId(): RecipientId? {
|
||||
val nonNull = this.filterNotNull()
|
||||
if (nonNull.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return if (nonNull.all { it.equals(nonNull[0]) }) {
|
||||
nonNull[0]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should only be used for debugging! A very destructive action that clears all known serviceIds.
|
||||
*/
|
||||
|
||||
@@ -48,9 +48,14 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
|
||||
private final net.zetetic.database.sqlcipher.SQLiteDatabase wrapped;
|
||||
private final Tracer tracer;
|
||||
|
||||
private static final ThreadLocal<Set<Runnable>> POST_TRANSACTION_TASKS = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Set<Runnable>> PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS;
|
||||
private static final ThreadLocal<Set<Runnable>> POST_SUCCESSFUL_TRANSACTION_TASKS;
|
||||
|
||||
static {
|
||||
POST_TRANSACTION_TASKS.set(new LinkedHashSet<>());
|
||||
PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS = new ThreadLocal<>();
|
||||
POST_SUCCESSFUL_TRANSACTION_TASKS = new ThreadLocal<>();
|
||||
|
||||
PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.set(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public SQLiteDatabase(net.zetetic.database.sqlcipher.SQLiteDatabase wrapped) {
|
||||
@@ -125,7 +130,7 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
|
||||
*/
|
||||
public void runPostSuccessfulTransaction(@NonNull Runnable task) {
|
||||
if (wrapped.inTransaction()) {
|
||||
getPostTransactionTasks().add(task);
|
||||
getPendingPostSuccessfulTransactionTasks().add(task);
|
||||
} else {
|
||||
task.run();
|
||||
}
|
||||
@@ -137,18 +142,29 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
|
||||
*/
|
||||
public void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable task) {
|
||||
if (wrapped.inTransaction()) {
|
||||
getPostTransactionTasks().add(new DedupedRunnable(dedupeKey, task));
|
||||
getPendingPostSuccessfulTransactionTasks().add(new DedupedRunnable(dedupeKey, task));
|
||||
} else {
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Set<Runnable> getPostTransactionTasks() {
|
||||
Set<Runnable> tasks = POST_TRANSACTION_TASKS.get();
|
||||
private @NonNull Set<Runnable> getPendingPostSuccessfulTransactionTasks() {
|
||||
Set<Runnable> tasks = PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.get();
|
||||
|
||||
if (tasks == null) {
|
||||
tasks = new LinkedHashSet<>();
|
||||
POST_TRANSACTION_TASKS.set(tasks);
|
||||
PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.set(tasks);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private @NonNull Set<Runnable> getPostSuccessfulTransactionTasks() {
|
||||
Set<Runnable> tasks = POST_SUCCESSFUL_TRANSACTION_TASKS.get();
|
||||
|
||||
if (tasks == null) {
|
||||
tasks = new LinkedHashSet<>();
|
||||
POST_SUCCESSFUL_TRANSACTION_TASKS.set(tasks);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
@@ -278,16 +294,16 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
|
||||
|
||||
@Override
|
||||
public void onCommit() {
|
||||
Set<Runnable> tasks = getPostTransactionTasks();
|
||||
for (Runnable r : new HashSet<>(tasks)) {
|
||||
r.run();
|
||||
}
|
||||
Set<Runnable> pendingTasks = getPendingPostSuccessfulTransactionTasks();
|
||||
Set<Runnable> tasks = getPostSuccessfulTransactionTasks();
|
||||
tasks.clear();
|
||||
tasks.addAll(pendingTasks);
|
||||
pendingTasks.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRollback() {
|
||||
getPostTransactionTasks().clear();
|
||||
getPendingPostSuccessfulTransactionTasks().clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -297,6 +313,12 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
|
||||
public void endTransaction() {
|
||||
trace("endTransaction()", wrapped::endTransaction);
|
||||
traceLockEnd();
|
||||
|
||||
Set<Runnable> tasks = getPostSuccessfulTransactionTasks();
|
||||
for (Runnable r : new HashSet<>(tasks)) {
|
||||
r.run();
|
||||
}
|
||||
tasks.clear();
|
||||
}
|
||||
|
||||
public void setTransactionSuccessful() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -195,5 +196,19 @@ class SessionDatabase(context: Context, databaseHelper: SignalDatabase) : Databa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if a session exists with this address for _any_ of your identities.
|
||||
*/
|
||||
fun hasAnySessionFor(addressName: String): Boolean {
|
||||
readableDatabase
|
||||
.select("1")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ADDRESS = ?", addressName)
|
||||
.run()
|
||||
.use { cursor ->
|
||||
return cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
class SessionRow(val address: String, val deviceId: Int, val record: SessionRecord)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES)
|
||||
executeStatements(db, DonationReceiptDatabase.CREATE_INDEXS)
|
||||
db.execSQL(StorySendsDatabase.CREATE_INDEX)
|
||||
executeStatements(db, DistributionListDatabase.CREATE_INDEXES)
|
||||
|
||||
executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS)
|
||||
executeStatements(db, ReactionDatabase.CREATE_TRIGGERS)
|
||||
|
||||
@@ -200,8 +200,10 @@ object SignalDatabaseMigrations {
|
||||
private const val GROUP_STORY_NOTIFICATIONS = 144
|
||||
private const val GROUP_STORY_REPLY_CLEANUP = 145
|
||||
private const val REMOTE_MEGAPHONE = 146
|
||||
private const val QUOTE_INDEX = 147
|
||||
private const val MY_STORY_PRIVACY_MODE = 148
|
||||
|
||||
const val DATABASE_VERSION = 146
|
||||
const val DATABASE_VERSION = 148
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -2613,6 +2615,49 @@ object SignalDatabaseMigrations {
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
if (oldVersion < QUOTE_INDEX) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON mms (quote_id, quote_author)
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
if (oldVersion < MY_STORY_PRIVACY_MODE) {
|
||||
db.execSQL("ALTER TABLE distribution_list ADD COLUMN privacy_mode INTEGER DEFAULT 0")
|
||||
db.execSQL("UPDATE distribution_list SET privacy_mode = 1 WHERE _id = 1")
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE distribution_list_member_tmp (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES distribution_list (_id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES recipient (_id),
|
||||
privacy_mode INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO distribution_list_member_tmp
|
||||
SELECT
|
||||
_id,
|
||||
list_id,
|
||||
recipient_id,
|
||||
0
|
||||
FROM distribution_list_member
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE distribution_list_member")
|
||||
db.execSQL("ALTER TABLE distribution_list_member_tmp RENAME TO distribution_list_member")
|
||||
|
||||
db.execSQL("UPDATE distribution_list_member SET privacy_mode = 1 WHERE list_id = 1")
|
||||
|
||||
db.execSQL("CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON distribution_list_member (list_id, recipient_id, privacy_mode)")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -48,6 +48,10 @@ public final class DistributionListId implements DatabaseId, Parcelable {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public boolean isMyStory() {
|
||||
return equals(MY_STORY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeLong(id);
|
||||
|
||||
@@ -7,5 +7,6 @@ data class DistributionListPartialRecord(
|
||||
val name: CharSequence,
|
||||
val recipientId: RecipientId,
|
||||
val allowsReplies: Boolean,
|
||||
val isUnknown: Boolean
|
||||
val isUnknown: Boolean,
|
||||
val privacyMode: DistributionListPrivacyMode
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
/**
|
||||
* Data needed to know how a distribution privacy settings are configured.
|
||||
*/
|
||||
data class DistributionListPrivacyData(
|
||||
val privacyMode: DistributionListPrivacyMode,
|
||||
val rawMemberCount: Int,
|
||||
val memberCount: Int
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
|
||||
/**
|
||||
* A list can explicit ([ONLY_WITH]) where only members of the list can send or exclusionary ([ALL_EXCEPT]) where
|
||||
* all connections are sent the story except for those members of the list. [ALL] is all of your Signal Connections.
|
||||
*/
|
||||
enum class DistributionListPrivacyMode(private val code: Long) {
|
||||
ONLY_WITH(0),
|
||||
ALL_EXCEPT(1),
|
||||
ALL(2);
|
||||
|
||||
val isBlockList: Boolean
|
||||
get() = this != ONLY_WITH
|
||||
|
||||
fun serialize(): Long {
|
||||
return code
|
||||
}
|
||||
|
||||
companion object Serializer : LongSerializer<DistributionListPrivacyMode> {
|
||||
override fun serialize(data: DistributionListPrivacyMode): Long {
|
||||
return data.serialize()
|
||||
}
|
||||
|
||||
override fun deserialize(data: Long): DistributionListPrivacyMode {
|
||||
return when (data) {
|
||||
ONLY_WITH.code -> ONLY_WITH
|
||||
ALL_EXCEPT.code -> ALL_EXCEPT
|
||||
ALL.code -> ALL
|
||||
else -> throw AssertionError("Unknown privacy mode: $data")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,16 @@ data class DistributionListRecord(
|
||||
val name: String,
|
||||
val distributionId: DistributionId,
|
||||
val allowsReplies: Boolean,
|
||||
val rawMembers: List<RecipientId>,
|
||||
val members: List<RecipientId>,
|
||||
val deletedAtTimestamp: Long,
|
||||
val isUnknown: Boolean
|
||||
)
|
||||
val isUnknown: Boolean,
|
||||
val privacyMode: DistributionListPrivacyMode
|
||||
) {
|
||||
fun getMembersToSync(): List<RecipientId> {
|
||||
return when (privacyMode) {
|
||||
DistributionListPrivacyMode.ALL -> emptyList()
|
||||
else -> rawMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,22 @@ data class RecipientRecord(
|
||||
return if (defaultSubscriptionId != -1) Optional.of(defaultSubscriptionId) else Optional.empty()
|
||||
}
|
||||
|
||||
fun e164Only(): Boolean {
|
||||
return this.e164 != null && this.serviceId == null
|
||||
}
|
||||
|
||||
fun sidOnly(sid: ServiceId): Boolean {
|
||||
return this.e164 == null && this.serviceId == sid && (this.pni == null || this.pni == sid)
|
||||
}
|
||||
|
||||
fun sidIsPni(): Boolean {
|
||||
return this.serviceId != null && this.pni != null && this.serviceId == this.pni
|
||||
}
|
||||
|
||||
fun pniAndAci(): Boolean {
|
||||
return this.serviceId != null && this.pni != null && this.serviceId != this.pni
|
||||
}
|
||||
|
||||
/**
|
||||
* A bundle of data that's only necessary when syncing to storage service, not for a
|
||||
* [Recipient].
|
||||
|
||||
@@ -93,7 +93,7 @@ public class HelpFragment extends LoggingFragment {
|
||||
emoji.add(view.findViewById(feeling.getViewId()));
|
||||
}
|
||||
|
||||
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories_3, android.R.layout.simple_spinner_item);
|
||||
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories_4, android.R.layout.simple_spinner_item);
|
||||
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
categorySpinner.setAdapter(categoryAdapter);
|
||||
@@ -209,7 +209,7 @@ public class HelpFragment extends LoggingFragment {
|
||||
suffix.append(getString(feeling.getStringId()));
|
||||
}
|
||||
|
||||
String[] englishCategories = ResourceUtil.getEnglishResources(requireContext()).getStringArray(R.array.HelpFragment__categories_3);
|
||||
String[] englishCategories = ResourceUtil.getEnglishResources(requireContext()).getStringArray(R.array.HelpFragment__categories_4);
|
||||
String category = (helpViewModel.getCategoryIndex() >= 0 && helpViewModel.getCategoryIndex() < englishCategories.length) ? englishCategories[helpViewModel.getCategoryIndex()]
|
||||
: categoryAdapter.getItem(helpViewModel.getCategoryIndex()).toString();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build;
|
||||
import androidx.annotation.GuardedBy;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
@@ -459,7 +460,8 @@ public class JobManager implements ConstraintObserver.Notifier {
|
||||
private final JobManager jobManager;
|
||||
private final List<List<Job>> jobs;
|
||||
|
||||
private Chain(@NonNull JobManager jobManager, @NonNull List<? extends Job> jobs) {
|
||||
@VisibleForTesting
|
||||
public Chain(@NonNull JobManager jobManager, @NonNull List<? extends Job> jobs) {
|
||||
this.jobManager = jobManager;
|
||||
this.jobs = new LinkedList<>();
|
||||
|
||||
@@ -489,7 +491,8 @@ public class JobManager implements ConstraintObserver.Notifier {
|
||||
enqueue();
|
||||
}
|
||||
|
||||
private List<List<Job>> getJobListChain() {
|
||||
@VisibleForTesting
|
||||
public List<List<Job>> getJobListChain() {
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -171,8 +172,9 @@ public final class AttachmentCompressionJob extends BaseJob {
|
||||
}
|
||||
} else if (constraints.canResize(attachment)) {
|
||||
Log.i(TAG, "Compressing image.");
|
||||
MediaStream converted = compressImage(context, attachment, constraints);
|
||||
attachmentDatabase.updateAttachmentData(attachment, converted, false);
|
||||
try (MediaStream converted = compressImage(context, attachment, constraints)) {
|
||||
attachmentDatabase.updateAttachmentData(attachment, converted, false);
|
||||
}
|
||||
attachmentDatabase.markAttachmentAsTransformed(attachmentId);
|
||||
} else if (constraints.isSatisfied(context, attachment)) {
|
||||
Log.i(TAG, "Not compressing.");
|
||||
@@ -240,8 +242,9 @@ public final class AttachmentCompressionJob extends BaseJob {
|
||||
}, outputStream, cancelationSignal);
|
||||
}
|
||||
|
||||
MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0);
|
||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited());
|
||||
try (MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0)) {
|
||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
|
||||
}
|
||||
} finally {
|
||||
if (!file.delete()) {
|
||||
Log.w(TAG, "Failed to delete temp file");
|
||||
@@ -259,15 +262,15 @@ public final class AttachmentCompressionJob extends BaseJob {
|
||||
if (transcoder.isTranscodeRequired()) {
|
||||
Log.i(TAG, "Compressing with android in-memory muxer");
|
||||
|
||||
MediaStream mediaStream = transcoder.transcode(percent -> {
|
||||
try (MediaStream mediaStream = transcoder.transcode(percent -> {
|
||||
notification.setProgress(100, percent);
|
||||
eventBus.postSticky(new PartProgressEvent(attachment,
|
||||
PartProgressEvent.Type.COMPRESSION,
|
||||
100,
|
||||
percent));
|
||||
}, cancelationSignal);
|
||||
|
||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited());
|
||||
}, cancelationSignal)) {
|
||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
|
||||
}
|
||||
|
||||
attachmentDatabase.markAttachmentAsTransformed(attachment.getAttachmentId());
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.migrations.EmojiDownloadMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.MigrationCompleteJob;
|
||||
import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.PassingMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.PinOptOutMigration;
|
||||
import org.thoughtcrime.securesms.migrations.PinReminderMigrationJob;
|
||||
@@ -206,6 +207,7 @@ public final class JobManagerFactories {
|
||||
put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory());
|
||||
put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory());
|
||||
put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory());
|
||||
put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory());
|
||||
put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory());
|
||||
put(PinReminderMigrationJob.KEY, new PinReminderMigrationJob.Factory());
|
||||
put(PniAccountInitializationMigrationJob.KEY, new PniAccountInitializationMigrationJob.Factory());
|
||||
|
||||
@@ -113,7 +113,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
Log.i(TAG, "First check, saving code and skipping download")
|
||||
values.highestVersionNoteReceived = BuildConfig.CANONICAL_VERSION_CODE
|
||||
}
|
||||
MessageDigest.isEqual(manifestMd5, values.previousManifestMd5) -> {
|
||||
!force && MessageDigest.isEqual(manifestMd5, values.previousManifestMd5) -> {
|
||||
Log.i(TAG, "Manifest has not changed since last fetch.")
|
||||
}
|
||||
else -> fetchManifest(manifestMd5)
|
||||
@@ -178,6 +178,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(values.releaseChannelRecipientId!!))
|
||||
var highestVersion = values.highestVersionNoteReceived
|
||||
var addedNewNotes = false
|
||||
|
||||
resolvedNotes
|
||||
.filterNotNull()
|
||||
@@ -197,7 +198,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
bodyRangeList.addButton(note.translation.callToActionText, note.releaseNote.ctaId, body.lastIndex, 0)
|
||||
}
|
||||
|
||||
ThreadUtil.sleep(1)
|
||||
ThreadUtil.sleep(5)
|
||||
val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement(
|
||||
recipientId = values.releaseChannelRecipientId!!,
|
||||
body = body,
|
||||
@@ -208,9 +209,8 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
imageHeight = note.translation.imageHeight?.toIntOrNull() ?: 0
|
||||
)
|
||||
|
||||
SignalDatabase.sms.insertBoostRequestMessage(values.releaseChannelRecipientId!!, threadId)
|
||||
|
||||
if (insertResult != null) {
|
||||
addedNewNotes = true
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(insertResult.messageId)
|
||||
.forEach { ApplicationDependencies.getJobManager().add(AttachmentDownloadJob(insertResult.messageId, it.attachmentId, false)) }
|
||||
|
||||
@@ -221,6 +221,11 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
}
|
||||
}
|
||||
|
||||
if (addedNewNotes) {
|
||||
ThreadUtil.sleep(5)
|
||||
SignalDatabase.sms.insertBoostRequestMessage(values.releaseChannelRecipientId!!, threadId)
|
||||
}
|
||||
|
||||
values.highestVersionNoteReceived = highestVersion
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,12 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
||||
ActiveSubscription.ChargeFailure chargeFailure = activeSubscription.getChargeFailure();
|
||||
if (chargeFailure != null) {
|
||||
Log.w(TAG, "Subscription payment charge failure code: " + chargeFailure.getCode() + ", message: " + chargeFailure.getMessage(), true);
|
||||
|
||||
if (!isForKeepAlive) {
|
||||
Log.w(TAG, "Initial subscription payment failed, treating as a permanent failure.");
|
||||
onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), false);
|
||||
throw new Exception("New subscription has hit a payment failure.");
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true);
|
||||
@@ -294,12 +300,31 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
||||
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status);
|
||||
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp);
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
||||
} else if (chargeFailure != null) {
|
||||
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
|
||||
DonationError.PaymentSetupError paymentSetupError;
|
||||
|
||||
if (declineCode.isKnown()) {
|
||||
paymentSetupError = new DonationError.PaymentSetupError.DeclinedError(
|
||||
getErrorSource(),
|
||||
new Exception(chargeFailure.getMessage()),
|
||||
declineCode
|
||||
);
|
||||
} else {
|
||||
paymentSetupError = new DonationError.PaymentSetupError.CodedError(
|
||||
getErrorSource(),
|
||||
new Exception("Card was declined. " + chargeFailure.getCode()),
|
||||
chargeFailure.getCode()
|
||||
);
|
||||
}
|
||||
|
||||
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, paymentSetupError);
|
||||
} else {
|
||||
Log.d(TAG, "Not for a keep-alive and we have a status. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, new DonationError.PaymentSetupError.DeclinedError(
|
||||
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, new DonationError.PaymentSetupError.GenericError(
|
||||
getErrorSource(),
|
||||
new Exception("Got a failure status from the subscription object."),
|
||||
StripeDeclineCode.Companion.getFromCode(status)
|
||||
new Exception("Got a failure status from the subscription object.")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
internal fun SignalStoreValues.longValue(key: String, default: Long): SignalStoreValueDelegate<Long> {
|
||||
@@ -26,6 +27,10 @@ internal fun SignalStoreValues.blobValue(key: String, default: ByteArray): Signa
|
||||
return BlobValue(key, default, this.store)
|
||||
}
|
||||
|
||||
internal fun <T : Any?> SignalStoreValues.enumValue(key: String, default: T, serializer: LongSerializer<T>): SignalStoreValueDelegate<T> {
|
||||
return KeyValueEnumValue(key, default, serializer, this.store)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin delegate that serves as a base for all other value types. This allows us to only expose this sealed
|
||||
* class to callers and protect the individual implementations as private behind the various extension functions.
|
||||
@@ -102,3 +107,17 @@ private class BlobValue(private val key: String, private val default: ByteArray,
|
||||
values.beginWrite().putBlob(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyValueEnumValue<T>(private val key: String, private val default: T, private val serializer: LongSerializer<T>, store: KeyValueStore) : SignalStoreValueDelegate<T>(store) {
|
||||
override fun getValue(values: KeyValueStore): T {
|
||||
return if (values.containsKey(key)) {
|
||||
serializer.deserialize(values.getLong(key, 0))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
override fun setValue(values: KeyValueStore, value: T) {
|
||||
values.beginWrite().putLong(key, serializer.serialize(value)).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.signal.core.util.StringSerializer;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SignalStoreList;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -50,7 +51,7 @@ abstract class SignalStoreValues {
|
||||
return store.getBlob(key, defaultValue);
|
||||
}
|
||||
|
||||
<T> List<T> getList(@NonNull String key, @NonNull Serializer<T> serializer) {
|
||||
<T> List<T> getList(@NonNull String key, @NonNull StringSerializer<T> serializer) {
|
||||
byte[] blob = getBlob(key, null);
|
||||
if (blob == null) {
|
||||
return Collections.emptyList();
|
||||
@@ -93,7 +94,7 @@ abstract class SignalStoreValues {
|
||||
store.beginWrite().putString(key, value).apply();
|
||||
}
|
||||
|
||||
<T> void putList(@NonNull String key, @NonNull List<T> values, @NonNull Serializer<T> serializer) {
|
||||
<T> void putList(@NonNull String key, @NonNull List<T> values, @NonNull StringSerializer<T> serializer) {
|
||||
putBlob(key, SignalStoreList.newBuilder()
|
||||
.addAllContents(values.stream()
|
||||
.map(serializer::serialize)
|
||||
@@ -105,9 +106,4 @@ abstract class SignalStoreValues {
|
||||
void remove(@NonNull String key) {
|
||||
store.beginWrite().remove(key).apply();
|
||||
}
|
||||
|
||||
interface Serializer<T> {
|
||||
@NonNull String serialize(@NonNull T data);
|
||||
T deserialize(@NonNull String data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.StringSerializer
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
|
||||
@@ -23,11 +24,32 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
* Rolling window of latest two private or group stories a user has sent to.
|
||||
*/
|
||||
private const val LATEST_STORY_SENDS = "latest.story.sends"
|
||||
|
||||
/**
|
||||
* Video Trim tooltip marker
|
||||
*/
|
||||
private const val VIDEO_TOOLTIP_SEEN_MARKER = "stories.video.will.be.trimmed.tooltip.seen"
|
||||
|
||||
/**
|
||||
* Cannot send to story tooltip marker
|
||||
*/
|
||||
private const val CANNOT_SEND_SEEN_MARKER = "stories.cannot.send.video.tooltip.seen"
|
||||
|
||||
/**
|
||||
* Whether or not the user has see the "Navigation education" view
|
||||
*/
|
||||
private const val USER_HAS_SEEN_FIRST_NAV_VIEW = "stories.user.has.seen.first.navigation.view"
|
||||
}
|
||||
|
||||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY)
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
|
||||
MANUAL_FEATURE_DISABLE,
|
||||
USER_HAS_ADDED_TO_A_STORY,
|
||||
VIDEO_TOOLTIP_SEEN_MARKER,
|
||||
CANNOT_SEND_SEEN_MARKER,
|
||||
USER_HAS_SEEN_FIRST_NAV_VIEW
|
||||
)
|
||||
|
||||
var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false)
|
||||
|
||||
@@ -35,6 +57,12 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
var userHasBeenNotifiedAboutStories: Boolean by booleanValue(USER_HAS_ADDED_TO_A_STORY, false)
|
||||
|
||||
var videoTooltipSeen by booleanValue(VIDEO_TOOLTIP_SEEN_MARKER, false)
|
||||
|
||||
var cannotSendTooltipSeen by booleanValue(CANNOT_SEND_SEEN_MARKER, false)
|
||||
|
||||
var userHasSeenFirstNavView: Boolean by booleanValue(USER_HAS_SEEN_FIRST_NAV_VIEW, false)
|
||||
|
||||
fun setLatestStorySend(storySend: StorySend) {
|
||||
synchronized(this) {
|
||||
val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)
|
||||
@@ -48,7 +76,7 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
return storySends.filter { it.timestamp >= activeCutoffTimestamp }
|
||||
}
|
||||
|
||||
private object StorySendSerializer : Serializer<StorySend> {
|
||||
private object StorySendSerializer : StringSerializer<StorySend> {
|
||||
|
||||
override fun serialize(data: StorySend): String {
|
||||
return JSONObject()
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.location.Address;
|
||||
import android.location.Geocoder;
|
||||
import android.os.AsyncTask;
|
||||
@@ -12,10 +14,12 @@ import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.animation.OvershootInterpolator;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.google.android.gms.maps.CameraUpdateFactory;
|
||||
@@ -46,6 +50,7 @@ public final class PlacePickerActivity extends AppCompatActivity {
|
||||
|
||||
private static final int ANIMATION_DURATION = 250;
|
||||
private static final OvershootInterpolator OVERSHOOT_INTERPOLATOR = new OvershootInterpolator();
|
||||
private static final String KEY_CHAT_COLOR = "chat_color";
|
||||
|
||||
private SingleAddressBottomSheet bottomSheet;
|
||||
private Address currentAddress;
|
||||
@@ -54,8 +59,8 @@ public final class PlacePickerActivity extends AppCompatActivity {
|
||||
private AddressLookup addressLookup;
|
||||
private GoogleMap googleMap;
|
||||
|
||||
public static void startActivityForResultAtCurrentLocation(@NonNull Fragment fragment, int requestCode) {
|
||||
fragment.startActivityForResult(new Intent(fragment.requireActivity(), PlacePickerActivity.class), requestCode);
|
||||
public static void startActivityForResultAtCurrentLocation(@NonNull Fragment fragment, int requestCode, @ColorInt int chatColor) {
|
||||
fragment.startActivityForResult(new Intent(fragment.requireActivity(), PlacePickerActivity.class).putExtra(KEY_CHAT_COLOR, chatColor), requestCode);
|
||||
}
|
||||
|
||||
public static AddressData addressFromData(@NonNull Intent data) {
|
||||
@@ -71,9 +76,9 @@ public final class PlacePickerActivity extends AppCompatActivity {
|
||||
View markerImage = findViewById(R.id.marker_image_view);
|
||||
View fab = findViewById(R.id.place_chosen_button);
|
||||
|
||||
ViewCompat.setBackgroundTintList(fab, ColorStateList.valueOf(getIntent().getIntExtra(KEY_CHAT_COLOR, Color.RED)));
|
||||
fab.setOnClickListener(v -> finishWithAddress());
|
||||
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ class VideoControlsDelegate {
|
||||
|
||||
private val playWhenReady: MutableMap<Uri, Boolean> = mutableMapOf()
|
||||
private var player: Player? = null
|
||||
private var isMuted: Boolean = true
|
||||
|
||||
fun getPlayerState(uri: Uri): PlayerState? {
|
||||
val player: Player? = this.player
|
||||
@@ -41,9 +42,29 @@ class VideoControlsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
fun mute() {
|
||||
isMuted = true
|
||||
player?.videoPlayer?.mute()
|
||||
}
|
||||
|
||||
fun unmute() {
|
||||
isMuted = false
|
||||
player?.videoPlayer?.unmute()
|
||||
}
|
||||
|
||||
fun hasAudioStream(): Boolean {
|
||||
return player?.videoPlayer?.hasAudioTrack() ?: false
|
||||
}
|
||||
|
||||
fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?, isGif: Boolean) {
|
||||
player = Player(uri, videoPlayer, isGif)
|
||||
|
||||
if (isMuted) {
|
||||
videoPlayer?.mute()
|
||||
} else {
|
||||
videoPlayer?.unmute()
|
||||
}
|
||||
|
||||
if (playWhenReady[uri] == true) {
|
||||
playWhenReady[uri] = false
|
||||
videoPlayer?.play()
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.Observer;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.MultiTransformation;
|
||||
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -72,6 +72,10 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
private OrderEnforcer<Stage> orderEnforcer;
|
||||
private Camera1Controller.Properties properties;
|
||||
|
||||
private final Observer<Optional<Media>> thumbObserver = this::presentRecentItemThumbnail;
|
||||
private boolean isThumbAvailable;
|
||||
private boolean isMediaSelected;
|
||||
|
||||
public static Camera1Fragment newInstance() {
|
||||
return new Camera1Fragment();
|
||||
}
|
||||
@@ -124,8 +128,6 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
|
||||
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
|
||||
|
||||
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
|
||||
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
// Let's assume portrait for now, so 9:16
|
||||
float aspectRatio = CameraFragment.getAspectRatioForOrientation(getResources().getConfiguration().orientation);
|
||||
@@ -173,7 +175,14 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
orderEnforcer.reset();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
controller.getMostRecentMediaItem().removeObserver(thumbObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
@@ -251,6 +260,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
|
||||
private void presentRecentItemThumbnail(Optional<Media> media) {
|
||||
if (media == null) {
|
||||
isThumbAvailable = false;
|
||||
updateGalleryVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,17 +277,36 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
thumbnail.setVisibility(View.GONE);
|
||||
thumbnail.setImageResource(0);
|
||||
}
|
||||
|
||||
isThumbAvailable = media.isPresent();
|
||||
updateGalleryVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void presentHud(int selectedMediaCount) {
|
||||
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
|
||||
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
|
||||
View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background);
|
||||
|
||||
if (selectedMediaCount > 0) {
|
||||
countButton.setVisibility(View.VISIBLE);
|
||||
countButton.setCount(selectedMediaCount);
|
||||
cameraGalleryContainer.setVisibility(View.GONE);
|
||||
} else {
|
||||
countButton.setVisibility(View.GONE);
|
||||
cameraGalleryContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
isMediaSelected = selectedMediaCount > 0;
|
||||
updateGalleryVisibility();
|
||||
}
|
||||
|
||||
private void updateGalleryVisibility() {
|
||||
View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background);
|
||||
|
||||
if (isMediaSelected || !isThumbAvailable) {
|
||||
cameraGalleryContainer.setVisibility(View.GONE);
|
||||
} else {
|
||||
cameraGalleryContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +318,9 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
View countButton = requireView().findViewById(R.id.camera_review_button);
|
||||
View toggleSpacer = requireView().findViewById(R.id.toggle_spacer);
|
||||
|
||||
controller.getMostRecentMediaItem().removeObserver(thumbObserver);
|
||||
controller.getMostRecentMediaItem().observeForever(thumbObserver);
|
||||
|
||||
if (toggleSpacer != null) {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
StoryDisplay storyDisplay = StoryDisplay.Companion.getStoryDisplay(getResources().getDisplayMetrics().widthPixels, getResources().getDisplayMetrics().heightPixels);
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
@@ -17,6 +18,7 @@ import android.view.animation.Interpolator;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -25,19 +27,20 @@ public class CameraButtonView extends View {
|
||||
|
||||
private enum CameraButtonMode { IMAGE, MIXED }
|
||||
|
||||
private static final int CAPTURE_ARC_STROKE_WIDTH = 4;
|
||||
private static final int HALF_CAPTURE_ARC_STROKE_WIDTH = CAPTURE_ARC_STROKE_WIDTH / 2;
|
||||
private static final float CAPTURE_ARC_STROKE_WIDTH = 3.5f;
|
||||
private static final int CAPTURE_FILL_PROTECTION = 10;
|
||||
private static final int PROGRESS_ARC_STROKE_WIDTH = 4;
|
||||
private static final int HALF_PROGRESS_ARC_STROKE_WIDTH = PROGRESS_ARC_STROKE_WIDTH / 2;
|
||||
private static final float DEADZONE_REDUCTION_PERCENT = 0.35f;
|
||||
private static final int DRAG_DISTANCE_MULTIPLIER = 3;
|
||||
private static final Interpolator ZOOM_INTERPOLATOR = new DecelerateInterpolator();
|
||||
|
||||
private final @NonNull Paint outlinePaint = outlinePaint();
|
||||
private final @NonNull Paint backgroundPaint = backgroundPaint();
|
||||
private final @NonNull Paint arcPaint = arcPaint();
|
||||
private final @NonNull Paint recordPaint = recordPaint();
|
||||
private final @NonNull Paint progressPaint = progressPaint();
|
||||
private final @NonNull Paint outlinePaint = outlinePaint();
|
||||
private final @NonNull Paint backgroundPaint = backgroundPaint();
|
||||
private final @NonNull Paint arcPaint = arcPaint();
|
||||
private final @NonNull Paint recordPaint = recordPaint();
|
||||
private final @NonNull Paint progressPaint = progressPaint();
|
||||
private final @NonNull Paint captureFillPaint = captureFillPaint();
|
||||
|
||||
private Animation growAnimation;
|
||||
private Animation shrinkAnimation;
|
||||
@@ -50,8 +53,8 @@ public class CameraButtonView extends View {
|
||||
|
||||
private final float imageCaptureSize;
|
||||
private final float recordSize;
|
||||
private final RectF progressRect = new RectF();
|
||||
private final Rect deadzoneRect = new Rect();
|
||||
private final RectF progressRect = new RectF();
|
||||
private final Rect deadzoneRect = new Rect();
|
||||
|
||||
private final @NonNull OnLongClickListener internalLongClickListener = v -> {
|
||||
notifyVideoCaptureStarted();
|
||||
@@ -112,10 +115,19 @@ public class CameraButtonView extends View {
|
||||
arcPaint.setColor(0xFFFFFFFF);
|
||||
arcPaint.setAntiAlias(true);
|
||||
arcPaint.setStyle(Paint.Style.STROKE);
|
||||
arcPaint.setStrokeWidth(ViewUtil.dpToPx(CAPTURE_ARC_STROKE_WIDTH));
|
||||
arcPaint.setStrokeWidth(DimensionUnit.DP.toPixels(CAPTURE_ARC_STROKE_WIDTH));
|
||||
return arcPaint;
|
||||
}
|
||||
|
||||
private static Paint captureFillPaint() {
|
||||
Paint arcPaint = new Paint();
|
||||
arcPaint.setColor(0xFFFFFFFF);
|
||||
arcPaint.setAntiAlias(true);
|
||||
arcPaint.setStyle(Paint.Style.FILL);
|
||||
return arcPaint;
|
||||
}
|
||||
|
||||
|
||||
private static Paint progressPaint() {
|
||||
Paint progressPaint = new Paint();
|
||||
progressPaint.setColor(0xFFFFFFFF);
|
||||
@@ -153,8 +165,8 @@ public class CameraButtonView extends View {
|
||||
|
||||
float radius = imageCaptureSize / 2f;
|
||||
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
|
||||
canvas.drawCircle(centerX, centerY, radius, outlinePaint);
|
||||
canvas.drawCircle(centerX, centerY, radius - ViewUtil.dpToPx(HALF_CAPTURE_ARC_STROKE_WIDTH), arcPaint);
|
||||
canvas.drawCircle(centerX, centerY, radius, arcPaint);
|
||||
canvas.drawCircle(centerX, centerY, radius - DimensionUnit.DP.toPixels(CAPTURE_FILL_PROTECTION), captureFillPaint);
|
||||
}
|
||||
|
||||
private void drawForVideoCapture(Canvas canvas) {
|
||||
|
||||
@@ -32,10 +32,12 @@ import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.camera.view.SignalCameraView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.util.Executors;
|
||||
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -49,11 +51,9 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.video.VideoUtil;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
@@ -76,6 +76,10 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
private View selfieFlash;
|
||||
private MemoryFileDescriptor videoFileDescriptor;
|
||||
|
||||
private final Observer<Optional<Media>> thumbObserver = this::presentRecentItemThumbnail;
|
||||
private boolean isThumbAvailable;
|
||||
private boolean isMediaSelected;
|
||||
|
||||
public static CameraXFragment newInstanceForAvatarCapture() {
|
||||
CameraXFragment fragment = new CameraXFragment();
|
||||
Bundle args = new Bundle();
|
||||
@@ -129,8 +133,6 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
|
||||
onOrientationChanged(getResources().getConfiguration().orientation);
|
||||
|
||||
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
|
||||
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
// Let's assume portrait for now, so 9:16
|
||||
float aspectRatio = CameraFragment.getAspectRatioForOrientation(getResources().getConfiguration().orientation);
|
||||
@@ -162,6 +164,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
controller.getMostRecentMediaItem().removeObserver(thumbObserver);
|
||||
closeVideoFileDescriptor();
|
||||
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
@@ -223,6 +226,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
|
||||
private void presentRecentItemThumbnail(Optional<Media> media) {
|
||||
if (media == null) {
|
||||
isThumbAvailable = false;
|
||||
updateGalleryVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,6 +243,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
thumbnail.setVisibility(View.GONE);
|
||||
thumbnail.setImageResource(0);
|
||||
}
|
||||
|
||||
isThumbAvailable = media.isPresent();
|
||||
updateGalleryVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -250,6 +258,19 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
} else {
|
||||
countButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
isMediaSelected = selectedMediaCount > 0;
|
||||
updateGalleryVisibility();
|
||||
}
|
||||
|
||||
private void updateGalleryVisibility() {
|
||||
View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background);
|
||||
|
||||
if (isMediaSelected || !isThumbAvailable) {
|
||||
cameraGalleryContainer.setVisibility(View.GONE);
|
||||
} else {
|
||||
cameraGalleryContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint({"ClickableViewAccessibility", "MissingPermission"})
|
||||
@@ -274,6 +295,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
}
|
||||
}
|
||||
|
||||
controller.getMostRecentMediaItem().removeObserver(thumbObserver);
|
||||
controller.getMostRecentMediaItem().observeForever(thumbObserver);
|
||||
|
||||
selfieFlash = requireView().findViewById(R.id.camera_selfie_flash);
|
||||
|
||||
captureButton.setOnClickListener(v -> {
|
||||
|
||||
@@ -144,7 +144,7 @@ public class MediaUploadRepository {
|
||||
@WorkerThread
|
||||
private void uploadMediaInternal(@NonNull Media media, @Nullable Recipient recipient) {
|
||||
Attachment attachment = asAttachment(context, media);
|
||||
PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient);
|
||||
PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient, MediaUtil.isVideo(media.getMimeType()));
|
||||
|
||||
if (result != null) {
|
||||
uploadResults.put(media, result);
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
@@ -157,6 +158,9 @@ class MediaSelectionActivity :
|
||||
}
|
||||
|
||||
private fun animateTextStyling(selectedSwitch: TextView, unselectedSwitch: TextView, duration: Long) {
|
||||
val offTextColor = ContextCompat.getColor(this, R.color.signal_colorOnSurface)
|
||||
val onTextColor = ContextCompat.getColor(this, R.color.signal_colorSecondaryContainer)
|
||||
|
||||
animateInShadowLayerValueAnimator?.cancel()
|
||||
animateInTextColorValueAnimator?.cancel()
|
||||
animateOutShadowLayerValueAnimator?.cancel()
|
||||
@@ -167,7 +171,7 @@ class MediaSelectionActivity :
|
||||
addUpdateListener { selectedSwitch.setShadowLayer(it.animatedValue as Float, 0f, 0f, Color.BLACK) }
|
||||
start()
|
||||
}
|
||||
animateInTextColorValueAnimator = ValueAnimator.ofInt(selectedSwitch.currentTextColor, Color.BLACK).apply {
|
||||
animateInTextColorValueAnimator = ValueAnimator.ofObject(ArgbEvaluatorCompat(), selectedSwitch.currentTextColor, onTextColor).apply {
|
||||
setEvaluator(ArgbEvaluatorCompat.getInstance())
|
||||
this.duration = duration
|
||||
addUpdateListener { selectedSwitch.setTextColor(it.animatedValue as Int) }
|
||||
@@ -178,7 +182,7 @@ class MediaSelectionActivity :
|
||||
addUpdateListener { unselectedSwitch.setShadowLayer(it.animatedValue as Float, 0f, 0f, Color.BLACK) }
|
||||
start()
|
||||
}
|
||||
animateOutTextColorValueAnimator = ValueAnimator.ofInt(unselectedSwitch.currentTextColor, Color.WHITE).apply {
|
||||
animateOutTextColorValueAnimator = ValueAnimator.ofObject(ArgbEvaluatorCompat(), unselectedSwitch.currentTextColor, offTextColor).apply {
|
||||
setEvaluator(ArgbEvaluatorCompat.getInstance())
|
||||
this.duration = duration
|
||||
addUpdateListener { unselectedSwitch.setTextColor(it.animatedValue as Int) }
|
||||
@@ -325,6 +329,10 @@ class MediaSelectionActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements {
|
||||
return viewModel.getStorySendRequirements()
|
||||
}
|
||||
|
||||
private inner class OnBackPressed : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val navController = Navigation.findNavController(this@MediaSelectionActivity, R.id.fragment_container)
|
||||
@@ -463,8 +471,4 @@ class MediaSelectionActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun canSendMediaToStories(): Boolean {
|
||||
return viewModel.canShareSelectedMediaToStory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult
|
||||
import org.thoughtcrime.securesms.sms.OutgoingStoryMessage
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageUtil
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -128,12 +128,22 @@ class MediaSelectionRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
val clippedMediaForStories = if (singleContact?.isStory == true || contacts.any { it.isStory }) {
|
||||
updatedMedia.filter { MediaUtil.isVideo(it.mimeType) }.map { media ->
|
||||
if (Stories.MediaTransform.getSendRequirements(media) == Stories.MediaTransform.SendRequirements.REQUIRES_CLIP) {
|
||||
Stories.MediaTransform.clipMediaToStoryDuration(media)
|
||||
} else {
|
||||
listOf(media)
|
||||
}
|
||||
}.flatten()
|
||||
} else emptyList()
|
||||
|
||||
uploadRepository.applyMediaUpdates(oldToNewMediaMap, singleRecipient)
|
||||
uploadRepository.updateCaptions(updatedMedia)
|
||||
uploadRepository.updateDisplayOrder(updatedMedia)
|
||||
uploadRepository.getPreUploadResults { uploadResults ->
|
||||
if (contacts.isNotEmpty()) {
|
||||
sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce)
|
||||
sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce, clippedMediaForStories)
|
||||
uploadRepository.deleteAbandonedAttachments()
|
||||
emitter.onComplete()
|
||||
} else if (uploadResults.isNotEmpty()) {
|
||||
@@ -210,10 +220,19 @@ class MediaSelectionRepository(context: Context) {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sendMessages(contacts: List<ContactSearchKey.RecipientSearchKey>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
|
||||
val broadcastMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
|
||||
val storyMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
|
||||
val distributionListSentTimestamps: MutableMap<PreUploadResult, Long> = mutableMapOf()
|
||||
private fun sendMessages(
|
||||
contacts: List<ContactSearchKey.RecipientSearchKey>,
|
||||
body: String,
|
||||
preUploadResults: Collection<PreUploadResult>,
|
||||
mentions: List<Mention>,
|
||||
isViewOnce: Boolean,
|
||||
storyClips: List<Media>
|
||||
) {
|
||||
val nonStoryMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
|
||||
val storyPreUploadMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
|
||||
val storyClipMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList()
|
||||
val distributionListPreUploadSentTimestamps: MutableMap<PreUploadResult, Long> = mutableMapOf()
|
||||
val distributionListStoryClipsSentTimestamps: MutableMap<Media, Long> = mutableMapOf()
|
||||
|
||||
for (contact in contacts) {
|
||||
val recipient = Recipient.resolved(contact.recipientId)
|
||||
@@ -237,7 +256,7 @@ class MediaSelectionRepository(context: Context) {
|
||||
recipient,
|
||||
body,
|
||||
emptyList(),
|
||||
if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(),
|
||||
if (recipient.isDistributionList) distributionListPreUploadSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(),
|
||||
-1,
|
||||
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
|
||||
isViewOnce,
|
||||
@@ -254,18 +273,57 @@ class MediaSelectionRepository(context: Context) {
|
||||
null
|
||||
)
|
||||
|
||||
if (isStory && preUploadResults.size > 1) {
|
||||
preUploadResults.forEach {
|
||||
val list = storyMessages[it] ?: mutableListOf()
|
||||
list.add(OutgoingSecureMediaMessage(message).withSentTimestamp(if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(it) { System.currentTimeMillis() } else System.currentTimeMillis()))
|
||||
storyMessages[it] = list
|
||||
if (isStory) {
|
||||
preUploadResults.filterNot { it.isVideo }.forEach {
|
||||
val list = storyPreUploadMessages[it] ?: mutableListOf()
|
||||
list.add(
|
||||
OutgoingSecureMediaMessage(message).withSentTimestamp(
|
||||
if (recipient.isDistributionList) {
|
||||
distributionListPreUploadSentTimestamps.getOrPut(it) { System.currentTimeMillis() }
|
||||
} else {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
)
|
||||
)
|
||||
storyPreUploadMessages[it] = list
|
||||
|
||||
// XXX We must do this to avoid sending out messages to the same recipient with the same
|
||||
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
|
||||
ThreadUtil.sleep(5)
|
||||
}
|
||||
|
||||
storyClips.forEach {
|
||||
storyClipMessages.add(
|
||||
OutgoingSecureMediaMessage(
|
||||
OutgoingMediaMessage(
|
||||
recipient,
|
||||
body,
|
||||
listOf(MediaUploadRepository.asAttachment(context, it)),
|
||||
if (recipient.isDistributionList) distributionListStoryClipsSentTimestamps.getOrPut(it) { System.currentTimeMillis() } else System.currentTimeMillis(),
|
||||
-1,
|
||||
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
|
||||
isViewOnce,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
storyType,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
mentions,
|
||||
mutableSetOf(),
|
||||
mutableSetOf(),
|
||||
null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// XXX We must do this to avoid sending out messages to the same recipient with the same
|
||||
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
|
||||
ThreadUtil.sleep(5)
|
||||
}
|
||||
} else {
|
||||
broadcastMessages.add(OutgoingSecureMediaMessage(message))
|
||||
nonStoryMessages.add(OutgoingSecureMediaMessage(message))
|
||||
|
||||
// XXX We must do this to avoid sending out messages to the same recipient with the same
|
||||
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
|
||||
@@ -273,19 +331,25 @@ class MediaSelectionRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (broadcastMessages.isNotEmpty()) {
|
||||
if (nonStoryMessages.isNotEmpty()) {
|
||||
Log.d(TAG, "Sending ${nonStoryMessages.size} non-story preupload messages")
|
||||
MessageSender.sendMediaBroadcast(
|
||||
context,
|
||||
broadcastMessages,
|
||||
preUploadResults,
|
||||
storyMessages.flatMap { (preUploadResult, messages) ->
|
||||
messages.map { OutgoingStoryMessage(it, preUploadResult) }
|
||||
}
|
||||
nonStoryMessages,
|
||||
preUploadResults
|
||||
)
|
||||
} else {
|
||||
storyMessages.forEach { (preUploadResult, messages) ->
|
||||
MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult), Collections.emptyList())
|
||||
}
|
||||
|
||||
if (storyPreUploadMessages.isNotEmpty()) {
|
||||
Log.d(TAG, "Sending ${storyPreUploadMessages.size} preload messages to stories")
|
||||
storyPreUploadMessages.forEach { (preUploadResult, messages) ->
|
||||
MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult))
|
||||
}
|
||||
}
|
||||
|
||||
if (storyClipMessages.isNotEmpty()) {
|
||||
Log.d(TAG, "Sending ${storyClipMessages.size} clip messages to stories")
|
||||
MessageSender.sendStories(context, storyClipMessages, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendConstants
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
|
||||
data class MediaSelectionState(
|
||||
val sendType: MessageSendType,
|
||||
@@ -22,7 +23,8 @@ data class MediaSelectionState(
|
||||
val isMeteredConnection: Boolean = false,
|
||||
val editorStateMap: Map<Uri, Any> = mapOf(),
|
||||
val cameraFirstCapture: Media? = null,
|
||||
val isStory: Boolean
|
||||
val isStory: Boolean,
|
||||
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
) {
|
||||
|
||||
val maxSelection = if (sendType.usesSmsTransport) {
|
||||
|
||||
@@ -9,7 +9,11 @@ import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
@@ -20,7 +24,7 @@ import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
@@ -39,6 +43,8 @@ class MediaSelectionViewModel(
|
||||
private val repository: MediaSelectionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedMediaSubject: Subject<List<Media>> = BehaviorSubject.create()
|
||||
|
||||
private val store: Store<MediaSelectionState> = Store(
|
||||
MediaSelectionState(
|
||||
sendType = sendType,
|
||||
@@ -83,6 +89,14 @@ class MediaSelectionViewModel(
|
||||
if (initialMedia.isNotEmpty()) {
|
||||
addMedia(initialMedia)
|
||||
}
|
||||
|
||||
disposables += selectedMediaSubject.map { media ->
|
||||
Stories.MediaTransform.getSendRequirements(media)
|
||||
}.subscribeBy { requirements ->
|
||||
store.update {
|
||||
it.copy(storySendRequirements = requirements)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -110,6 +124,10 @@ class MediaSelectionViewModel(
|
||||
return store.state.isStory
|
||||
}
|
||||
|
||||
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements {
|
||||
return store.state.storySendRequirements
|
||||
}
|
||||
|
||||
private fun addMedia(media: List<Media>) {
|
||||
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
|
||||
addAll(store.state.selectedMedia)
|
||||
@@ -128,6 +146,8 @@ class MediaSelectionViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
selectedMediaSubject.onNext(filterResult.filteredMedia)
|
||||
|
||||
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
|
||||
startUpload(newMedia)
|
||||
}
|
||||
@@ -212,6 +232,7 @@ class MediaSelectionViewModel(
|
||||
mediaErrors.postValue(MediaValidator.FilterError.NoItems())
|
||||
}
|
||||
|
||||
selectedMediaSubject.onNext(newMediaList)
|
||||
repository.deleteBlobs(listOf(media))
|
||||
|
||||
cancelUpload(media)
|
||||
@@ -345,10 +366,6 @@ class MediaSelectionViewModel(
|
||||
return store.state.selectedMedia.isNotEmpty()
|
||||
}
|
||||
|
||||
fun canShareSelectedMediaToStory(): Boolean {
|
||||
return store.state.selectedMedia.all { MultiShareArgs.isValidStoryDuration(it) }
|
||||
}
|
||||
|
||||
fun onRestoreState(savedInstanceState: Bundle) {
|
||||
val selection: List<Media> = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList()
|
||||
val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED)
|
||||
@@ -362,6 +379,8 @@ class MediaSelectionViewModel(
|
||||
val editorStates: List<Bundle> = savedInstanceState.getParcelableArrayList(STATE_EDITORS) ?: emptyList()
|
||||
val editorStateMap = editorStates.associate { it.toAssociation() }
|
||||
|
||||
selectedMediaSubject.onNext(selection)
|
||||
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
selectedMedia = selection,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
object MediaValidator {
|
||||
|
||||
@WorkerThread
|
||||
fun filterMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int, isStory: Boolean): FilterResult {
|
||||
val filteredMedia = filterForValidMedia(context, media, mediaConstraints, isStory)
|
||||
val isAllMediaValid = filteredMedia.size == media.size
|
||||
@@ -46,6 +48,7 @@ object MediaValidator {
|
||||
return FilterResult(truncatedMedia, error, bucketId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun filterForValidMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, isStory: Boolean): List<Media> {
|
||||
return media
|
||||
.filter { m -> isSupportedMediaType(m.mimeType) }
|
||||
@@ -53,7 +56,7 @@ object MediaValidator {
|
||||
MediaUtil.isImageAndNotGif(m.mimeType) || isValidGif(context, m, mediaConstraints) || isValidVideo(context, m, mediaConstraints)
|
||||
}
|
||||
.filter { m ->
|
||||
MediaConstraints.isVideoTranscodeAvailable() || !isStory || MultiShareArgs.isValidStoryDuration(m)
|
||||
!isStory || Stories.MediaTransform.getSendRequirements(m) != Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,19 +65,20 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
|
||||
this,
|
||||
contactRecycler,
|
||||
FeatureFlags.shareSelectionLimit(),
|
||||
true
|
||||
) { state ->
|
||||
ContactSearchConfiguration.build {
|
||||
query = state.query
|
||||
true,
|
||||
{ state ->
|
||||
ContactSearchConfiguration.build {
|
||||
query = state.query
|
||||
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Groups(
|
||||
includeHeader = false,
|
||||
returnAsGroupStories = true
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Groups(
|
||||
includeHeader = false,
|
||||
returnAsGroupStories = true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mediator.getSelectionState().observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendReposi
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostView
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.privacy.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
|
||||
@@ -29,13 +30,13 @@ import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.privacy.HideStoryFromDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
|
||||
class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback {
|
||||
class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback, WrapperDialogFragment.WrapperDialogFragmentCallback {
|
||||
|
||||
private lateinit var shareListWrapper: View
|
||||
private lateinit var shareSelectionRecyclerView: RecyclerView
|
||||
@@ -123,7 +124,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
|
||||
}
|
||||
|
||||
val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true) { contactSearchState ->
|
||||
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true, { contactSearchState ->
|
||||
ContactSearchConfiguration.build {
|
||||
query = contactSearchState.query
|
||||
|
||||
@@ -135,7 +136,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { selection ->
|
||||
shareSelectionAdapter.submitList(selection.mapIndexed { index, contact -> ShareSelectionMappingModel(contact.requireShareContact(), index == 0) })
|
||||
@@ -195,4 +196,8 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
|
||||
override fun onGroupStoryClicked() {
|
||||
ChooseGroupStoryBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
override fun onWrapperDialogFragmentDismissed() {
|
||||
contactSearchMediator.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
|
||||
private const val VIDEO_EDITOR_TAG = "video.editor.fragment"
|
||||
@@ -102,7 +103,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide
|
||||
private fun requireMaxCompressedVideoSize(): Long = sharedViewModel.getMediaConstraints().getCompressedVideoMaxSize(requireContext()).toLong()
|
||||
private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()).toLong()
|
||||
private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF))
|
||||
private fun requireMaxVideoDuration(): Long = if (sharedViewModel.isStory()) Stories.MAX_VIDEO_DURATION_MILLIS else Long.MAX_VALUE
|
||||
private fun requireMaxVideoDuration(): Long = if (sharedViewModel.isStory() && !MediaConstraints.isVideoTranscodeAvailable()) Stories.MAX_VIDEO_DURATION_MILLIS else Long.MAX_VALUE
|
||||
|
||||
companion object {
|
||||
private const val ARG_URI = "arg.uri"
|
||||
|
||||
@@ -16,17 +16,12 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.MessageDetailsViewState<?>, RecyclerView.ViewHolder> {
|
||||
|
||||
private static final Object EXPIRATION_TIMER_CHANGE_PAYLOAD = new Object();
|
||||
|
||||
private final LifecycleOwner lifecycleOwner;
|
||||
private final GlideRequests glideRequests;
|
||||
private final Colorizer colorizer;
|
||||
private Callbacks callbacks;
|
||||
private boolean running;
|
||||
private final Callbacks callbacks;
|
||||
|
||||
MessageDetailsAdapter(@NonNull LifecycleOwner lifecycleOwner, @NonNull GlideRequests glideRequests, @NonNull Colorizer colorizer, @NonNull Callbacks callbacks) {
|
||||
super(new MessageDetailsDiffer());
|
||||
@@ -34,7 +29,6 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
|
||||
this.glideRequests = glideRequests;
|
||||
this.colorizer = colorizer;
|
||||
this.callbacks = callbacks;
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,7 +48,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
if (holder instanceof MessageHeaderViewHolder) {
|
||||
((MessageHeaderViewHolder) holder).bind(lifecycleOwner, (ConversationMessage) getItem(position).data, running);
|
||||
((MessageHeaderViewHolder) holder).bind(lifecycleOwner, (ConversationMessage) getItem(position).data);
|
||||
} else if (holder instanceof RecipientHeaderViewHolder) {
|
||||
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
|
||||
} else if (holder instanceof RecipientViewHolder) {
|
||||
@@ -64,34 +58,11 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
|
||||
if (payloads.isEmpty()) {
|
||||
super.onBindViewHolder(holder, position, payloads);
|
||||
} else if (holder instanceof MessageHeaderViewHolder) {
|
||||
((MessageHeaderViewHolder) holder).partialBind((ConversationMessage) getItem(position).data, running);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
return getItem(position).itemType;
|
||||
}
|
||||
|
||||
void resumeMessageExpirationTimer() {
|
||||
running = true;
|
||||
if (getItemCount() > 0) {
|
||||
notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD);
|
||||
}
|
||||
}
|
||||
|
||||
void pauseMessageExpirationTimer() {
|
||||
running = false;
|
||||
if (getItemCount() > 0) {
|
||||
notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MessageDetailsDiffer extends DiffUtil.ItemCallback<MessageDetailsViewState<?>> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull MessageDetailsViewState<?> oldItem, @NonNull MessageDetailsViewState<?> newItem) {
|
||||
|
||||
@@ -76,18 +76,6 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
|
||||
initializeVideoPlayer(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
adapter.resumeMessageExpirationTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
adapter.pauseMessageExpirationTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
@@ -104,7 +92,7 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
|
||||
View toolbarShadow = view.findViewById(R.id.toolbar_shadow);
|
||||
|
||||
colorizer = new Colorizer();
|
||||
adapter = new MessageDetailsAdapter(this, glideRequests, colorizer, this::onErrorClicked);
|
||||
adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this::onErrorClicked);
|
||||
recyclerViewColorizer = new RecyclerViewColorizer(list);
|
||||
|
||||
list.setAdapter(adapter);
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messagedetails;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.os.CountDownTimer;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.StyleSpan;
|
||||
@@ -13,12 +14,12 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItem;
|
||||
@@ -40,23 +41,24 @@ import java.text.SimpleDateFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
|
||||
private final TextView sentDate;
|
||||
private final TextView receivedDate;
|
||||
private final TextView expiresIn;
|
||||
private final TextView transport;
|
||||
private final TextView errorText;
|
||||
private final View resendButton;
|
||||
private final View messageMetadata;
|
||||
private final ViewStub updateStub;
|
||||
private final ViewStub sentStub;
|
||||
private final ViewStub receivedStub;
|
||||
private final Colorizer colorizer;
|
||||
private final TextView sentDate;
|
||||
private final TextView receivedDate;
|
||||
private final TextView expiresIn;
|
||||
private final TextView transport;
|
||||
private final TextView errorText;
|
||||
private final View resendButton;
|
||||
private final View messageMetadata;
|
||||
private final ViewStub updateStub;
|
||||
private final ViewStub sentStub;
|
||||
private final ViewStub receivedStub;
|
||||
private final Colorizer colorizer;
|
||||
private final GlideRequests glideRequests;
|
||||
|
||||
private GlideRequests glideRequests;
|
||||
private ConversationItem conversationItem;
|
||||
private ExpiresUpdater expiresUpdater;
|
||||
private ConversationItem conversationItem;
|
||||
private CountDownTimer expiresUpdater;
|
||||
|
||||
MessageHeaderViewHolder(@NonNull View itemView, GlideRequests glideRequests, @NonNull Colorizer colorizer) {
|
||||
super(itemView);
|
||||
@@ -75,20 +77,16 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia);
|
||||
}
|
||||
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage, boolean running) {
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) {
|
||||
MessageRecord messageRecord = conversationMessage.getMessageRecord();
|
||||
bindMessageView(lifecycleOwner, conversationMessage);
|
||||
bindErrorState(messageRecord);
|
||||
bindSentReceivedDates(messageRecord);
|
||||
bindExpirationTime(messageRecord, running);
|
||||
bindExpirationTime(lifecycleOwner, messageRecord);
|
||||
bindTransport(messageRecord);
|
||||
}
|
||||
|
||||
void partialBind(ConversationMessage conversationMessage, boolean running) {
|
||||
bindExpirationTime(conversationMessage.getMessageRecord(), running);
|
||||
}
|
||||
|
||||
private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage) {
|
||||
private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) {
|
||||
if (conversationItem == null) {
|
||||
if (conversationMessage.getMessageRecord().isGroupAction()) {
|
||||
conversationItem = (ConversationItem) updateStub.inflate();
|
||||
@@ -111,7 +109,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
colorizer);
|
||||
colorizer,
|
||||
false);
|
||||
}
|
||||
|
||||
private void bindErrorState(MessageRecord messageRecord) {
|
||||
@@ -144,7 +143,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
sentDate.setText(formatBoldString(R.string.message_details_header_sent, "-"));
|
||||
receivedDate.setVisibility(View.GONE);
|
||||
} else {
|
||||
Locale dateLocale = Locale.getDefault();
|
||||
Locale dateLocale = Locale.getDefault();
|
||||
SimpleDateFormat dateFormatter = DateUtils.getDetailedDateFormatter(itemView.getContext(), dateLocale);
|
||||
sentDate.setText(formatBoldString(R.string.message_details_header_sent, dateFormatter.format(new Date(messageRecord.getDateSent()))));
|
||||
sentDate.setOnLongClickListener(v -> {
|
||||
@@ -165,9 +164,9 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
}
|
||||
}
|
||||
|
||||
private void bindExpirationTime(final MessageRecord messageRecord, boolean running) {
|
||||
private void bindExpirationTime(@NonNull LifecycleOwner lifecycleOwner, @NonNull MessageRecord messageRecord) {
|
||||
if (expiresUpdater != null) {
|
||||
expiresUpdater.stop();
|
||||
expiresUpdater.cancel();
|
||||
expiresUpdater = null;
|
||||
}
|
||||
|
||||
@@ -177,10 +176,36 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
}
|
||||
|
||||
expiresIn.setVisibility(View.VISIBLE);
|
||||
if (running) {
|
||||
expiresUpdater = new ExpiresUpdater(messageRecord);
|
||||
ThreadUtil.runOnMain(expiresUpdater);
|
||||
}
|
||||
|
||||
lifecycleOwner.getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
||||
@Override
|
||||
public void onResume(@NonNull LifecycleOwner owner) {
|
||||
if (expiresUpdater != null) {
|
||||
expiresUpdater.cancel();
|
||||
}
|
||||
expiresUpdater = new CountDownTimer(messageRecord.getExpiresIn(), TimeUnit.SECONDS.toMillis(1)) {
|
||||
@Override
|
||||
public void onTick(long millisUntilFinished) {
|
||||
int expirationTime = Math.max((int) (TimeUnit.MILLISECONDS.toSeconds(millisUntilFinished)), 1);
|
||||
String duration = ExpirationUtil.getExpirationDisplayValue(itemView.getContext(), expirationTime);
|
||||
|
||||
expiresIn.setText(formatBoldString(R.string.message_details_header_disappears, duration));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinish() {}
|
||||
};
|
||||
expiresUpdater.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull LifecycleOwner owner) {
|
||||
if (expiresUpdater != null) {
|
||||
expiresUpdater.cancel();
|
||||
expiresUpdater = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void bindTransport(MessageRecord messageRecord) {
|
||||
@@ -254,35 +279,4 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
||||
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
|
||||
return conversationItem.getColorizerProjections(coordinateRoot);
|
||||
}
|
||||
|
||||
private class ExpiresUpdater implements Runnable {
|
||||
|
||||
private final long expireStartedTimestamp;
|
||||
private final long expiresInTimestamp;
|
||||
private boolean running;
|
||||
|
||||
ExpiresUpdater(MessageRecord messageRecord) {
|
||||
expireStartedTimestamp = messageRecord.getExpireStarted();
|
||||
expiresInTimestamp = messageRecord.getExpiresIn();
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
long elapsed = System.currentTimeMillis() - expireStartedTimestamp;
|
||||
long remaining = expiresInTimestamp - elapsed;
|
||||
int expirationTime = Math.max((int) (remaining / 1000), 1);
|
||||
String duration = ExpirationUtil.getExpirationDisplayValue(itemView.getContext(), expirationTime);
|
||||
|
||||
expiresIn.setText(formatBoldString(R.string.message_details_header_disappears, duration));
|
||||
|
||||
if (running && expirationTime > 1) {
|
||||
ThreadUtil.runOnMainDelayed(this, 500);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user