Compare commits

..

47 Commits

Author SHA1 Message Date
Cody Henthorne
f2e919f39f Bump version to 5.42.0 2022-06-29 15:43:53 -04:00
Cody Henthorne
19080a8a5e Updated language translations. 2022-06-29 15:36:18 -04:00
Greyson Parrelli
61ce39b5b6 Improve implementation and testing on PNP contact merging. 2022-06-29 15:32:26 -04:00
Alex Hart
c64be82710 Add context menus to story contacts in contact selection. 2022-06-29 15:32:25 -04:00
Alex Hart
7bd34d2b99 Reimplement contact chips with a recyclerview. 2022-06-29 15:32:25 -04:00
Cody Henthorne
4215b0391d Fix leak in Message Details for disappearing messages. 2022-06-29 15:32:25 -04:00
Cody Henthorne
96ea4c0cc2 Fix gift plurals resource. 2022-06-29 15:32:25 -04:00
Cody Henthorne
1129ca28fb Revert "Disable voice note proximity sensor when using bluetooth headset. (#2448)"
This reverts commit 9c7a5e3cc8.
2022-06-29 15:32:25 -04:00
Alex Hart
ba6e1b5dd5 Fix attachment deduplication issue with Stories. 2022-06-29 15:32:25 -04:00
Cody Henthorne
ed25be2e23 Fix couple more places where Note to Self should be used. 2022-06-29 15:32:25 -04:00
Cody Henthorne
7a0bd3315b Update release channel with material 3 changes. 2022-06-29 15:32:25 -04:00
Alex Hart
8b806a8ac5 Isolate and add unit testing to new link logic.
Co-Authored-By: ylpoonlg <56300571+ylpoonlg@users.noreply.github.com>
2022-06-29 15:32:25 -04:00
Alex Hart
0ac5782f1f Ensure stub is never resolved if not needed. 2022-06-29 15:32:25 -04:00
Alex Hart
e10c20ffd7 Fix issue with getUnreadStories query. 2022-06-29 15:32:25 -04:00
ylpoonlg
86227fbd67 Fix url trailing symbol.
Fixes #12309
Fixes #10898
Fixes #11310
2022-06-29 15:32:25 -04:00
Alex Hart
1cfa5c31f2 Implement correct video story sound behaviour. 2022-06-29 15:32:25 -04:00
Alex Hart
521bd2cce4 Implement first-time-nav screen for stories. 2022-06-29 15:32:25 -04:00
Alex Hart
858c7a7f2e Implement "unviewed only" mode for story viewer. 2022-06-29 15:32:25 -04:00
Cody Henthorne
89a6730efe Add Storage Service plugin to Spinner. 2022-06-29 15:32:25 -04:00
Cody Henthorne
9bc25132c3 Add new My Story privacy settings. 2022-06-29 15:32:25 -04:00
Alex Hart
ebc556801e Ensure story media is only uploaded once. 2022-06-29 15:32:25 -04:00
Alex Hart
6b745ba58a Allow swipe up to close viewer when viewing last story. 2022-06-29 15:32:25 -04:00
Alex Hart
6ddb5b983f Implement proper error handling for charge failure on initial subscription attempt. 2022-06-29 15:32:25 -04:00
Alex Hart
8efd07b3e2 Fix diplay issue with note to self banner. 2022-06-29 15:32:25 -04:00
Alex Hart
e85adad2b4 Add safety net for when the user has disabled their contacts app. 2022-06-29 15:32:25 -04:00
Alex Hart
678a6f86ab Change several creations of alertdialogs to use materialalertdialogbuilder. 2022-06-29 15:32:25 -04:00
Jim Gustafson
9dc061e64f Update to RingRTC v2.20.10 2022-06-29 15:32:25 -04:00
Frazer Smith
2fed3f7e90 Update github actions with latest versions.
Closes #12294
2022-06-29 15:32:25 -04:00
Alex Hart
af362736de Update help categories. 2022-06-29 15:32:25 -04:00
Cody Henthorne
d39a4b14e7 Only add one sustainer request message per release notes update. 2022-06-29 15:32:25 -04:00
Alex Hart
6a385c7a22 Implement video length enforcement for Stories. 2022-06-28 15:42:15 -04:00
Alex Hart
2c3d8337c3 Include self in recents section. 2022-06-28 15:42:15 -04:00
Alex Hart
28feba6a6c Add proper catch for ISE in video thumb extractor. 2022-06-28 15:42:15 -04:00
Greyson Parrelli
6ec7834046 Add the ability to see replies. 2022-06-28 15:42:15 -04:00
Alex Hart
ee4f3abf22 Add unit testing for pinned last message deletion fix. 2022-06-28 15:42:14 -04:00
Alex Hart
dc66583ef1 Update camera UX to match Material3 Spec. 2022-06-28 15:42:14 -04:00
Alex Hart
d30714bfd4 Update coloring of capture first flow toggle.: 2022-06-28 15:42:14 -04:00
Alex Hart
d04d2f7e93 Fix bad centering of emoji button in add message fragment. 2022-06-28 15:42:14 -04:00
Alex Hart
1328aab939 Add material3 coloring to story reply dialog. 2022-06-28 15:42:14 -04:00
Alex Hart
2a9d2cf580 Remove bottomsheet elevation tinting. 2022-06-28 15:42:14 -04:00
Jim Gustafson
a316650aee Update to RingRTC v2.20.9 2022-06-28 15:42:14 -04:00
Alex Hart
4d1e8b8f75 Update several story ui elements for Material3. 2022-06-28 15:42:14 -04:00
Alex Hart
9c7a5e3cc8 Disable voice note proximity sensor when using bluetooth headset. (#2448) 2022-06-28 15:42:14 -04:00
Alex Hart
2022dae37a Draw pulse outliner in onDrawForeground instead of in onDraw. 2022-06-28 15:42:14 -04:00
Chris Eager
05b7055678 Update device-transfer app build to work with the latest libsignal 2022-06-28 15:42:14 -04:00
Alex Hart
53c60e1f6d Add proper coloring to send buttons. 2022-06-28 15:42:14 -04:00
Alex Hart
cd8fa58d7e Fix voice note playback bar for RTL regions. 2022-06-28 15:42:14 -04:00
316 changed files with 11088 additions and 2492 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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