mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-16 23:13:17 +01:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c79840684 | ||
|
|
4ba7de9519 | ||
|
|
2eb8df347e | ||
|
|
9056371c41 | ||
|
|
1f57e1f366 | ||
|
|
aeb568bcf4 | ||
|
|
b7afe4411e | ||
|
|
cba784b8ec | ||
|
|
3aba15e88d | ||
|
|
fa384e93dc | ||
|
|
1f3e04da29 | ||
|
|
a484d48377 | ||
|
|
15f51ea26e | ||
|
|
80bfa103ab | ||
|
|
66f93e0d32 | ||
|
|
366780f6cb | ||
|
|
fb4c1fc268 | ||
|
|
e3bb7ccbd3 | ||
|
|
e3fb8a2137 | ||
|
|
ba4c0386ef | ||
|
|
644945825b | ||
|
|
7590c6dcbb | ||
|
|
b25cef86ee | ||
|
|
fdaaa560e7 | ||
|
|
4cd438b2db | ||
|
|
c0e1507ef4 | ||
|
|
8a75d78ce7 | ||
|
|
8176d25b4c | ||
|
|
21273bc165 | ||
|
|
213517f875 | ||
|
|
b1c006657a | ||
|
|
852dcd9711 | ||
|
|
427e73f7fd | ||
|
|
eae6a971e6 | ||
|
|
4b23e60dd6 | ||
|
|
f0988f37f3 | ||
|
|
e2e3617be9 | ||
|
|
3ac63cc59d | ||
|
|
d935d1deca | ||
|
|
3b1b00027b | ||
|
|
a1bc1aaa98 | ||
|
|
0ccaad1462 | ||
|
|
ad57e62680 | ||
|
|
4e57432dbb | ||
|
|
63412b0153 | ||
|
|
35199abf1f | ||
|
|
41b5813984 | ||
|
|
83215bb98f | ||
|
|
eb12395b8e | ||
|
|
4b07da4978 | ||
|
|
3fbc5423e5 | ||
|
|
9d9e6e2972 | ||
|
|
56a8451d07 | ||
|
|
2483a92975 | ||
|
|
34bbb98c96 | ||
|
|
155bdf6164 | ||
|
|
5358ed6eff | ||
|
|
4f3bb39e5c | ||
|
|
8a49534e2b | ||
|
|
2c3228d6df | ||
|
|
c82d518d4d | ||
|
|
35cd36e9fe | ||
|
|
ee176cbe3d | ||
|
|
bd915cdd7f | ||
|
|
c27f5787fe | ||
|
|
f6cdf459bb | ||
|
|
4e851f90df | ||
|
|
8d8a2a8eef | ||
|
|
277c17de83 | ||
|
|
d5fd424b95 | ||
|
|
e701e4bff0 | ||
|
|
0ddfb4456b | ||
|
|
69dc31681d | ||
|
|
2d7655a6bb | ||
|
|
fe088c39c7 | ||
|
|
731714d263 | ||
|
|
c165636180 | ||
|
|
372dd13eba | ||
|
|
b35ef0bb4d | ||
|
|
bd58c91d2c | ||
|
|
9a5fcdbe4d | ||
|
|
2452056cbe | ||
|
|
bdf7e5d367 | ||
|
|
aae683af41 | ||
|
|
174cd860a0 | ||
|
|
765185952e | ||
|
|
f4002850bb | ||
|
|
935dd7de45 | ||
|
|
d6b6884c69 | ||
|
|
2ed39e4448 | ||
|
|
2de5ea43fb | ||
|
|
88d2d4d9c7 | ||
|
|
aff0c43b39 | ||
|
|
bd18b731c8 | ||
|
|
7b499f96be | ||
|
|
63dab3f4b0 | ||
|
|
80598814bd | ||
|
|
b00abf1667 | ||
|
|
9594be8fcf | ||
|
|
acecd5f013 | ||
|
|
2d1efb604c | ||
|
|
a84c971cbe | ||
|
|
7564ef4811 | ||
|
|
01e75120a7 | ||
|
|
1314b04994 | ||
|
|
253cc5fec4 | ||
|
|
c296a28a4a | ||
|
|
ff95319559 | ||
|
|
3aa770ee08 | ||
|
|
653410cf27 | ||
|
|
ba08dbef5f | ||
|
|
c1df628079 | ||
|
|
e72cac7db5 | ||
|
|
cbfa573d3d | ||
|
|
1b404cef34 | ||
|
|
cb66996407 | ||
|
|
96f908b068 | ||
|
|
472c8a441f | ||
|
|
1f0c56546e | ||
|
|
97f8b5988d | ||
|
|
19dc90b68b | ||
|
|
67f0ba8624 | ||
|
|
a23c27b54b | ||
|
|
34dec1aec2 | ||
|
|
4f1aa34a46 | ||
|
|
a207bf965a | ||
|
|
33457acee2 | ||
|
|
80622147ab | ||
|
|
719f5e28d0 | ||
|
|
c2830163b8 | ||
|
|
bec9b3d88c | ||
|
|
8e25719b7b |
@@ -59,7 +59,7 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2021 Signal
|
||||
Copyright 2013-2022 Signal
|
||||
|
||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ apply from: 'translations.gradle'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'app.cash.exhaustive'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply from: 'static-ips.gradle'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
@@ -62,8 +63,8 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1011
|
||||
def canonicalVersionName = "5.32.8"
|
||||
def canonicalVersionCode = 1022
|
||||
def canonicalVersionName = "5.33.3"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -186,9 +187,17 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String[]", "SIGNAL_SERVICE_IPS", service_ips
|
||||
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDS_IPS", cds_ips
|
||||
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
|
||||
buildConfigField "String", "CDSH_CODE_HASH", "\"ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2\""
|
||||
buildConfigField "String", "CDSH_CODE_HASH", "\"2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||
@@ -203,8 +212,8 @@ android {
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
buildConfigField "int[]", "MOBILE_COIN_BLACKLIST", "new int[]{98,963,53,850,7}"
|
||||
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
|
||||
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
|
||||
@@ -343,6 +352,7 @@ android {
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.UUID
|
||||
|
||||
class DistributionListDatabaseTest {
|
||||
|
||||
private lateinit var distributionDatabase: DistributionListDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
distributionDatabase = SignalDatabase.distributionLists
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createList_whenNoConflict_insertSuccessfully() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createList_whenNameConflict_failToInsert() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val id2: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNull(id2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getList_returnCorrectList() {
|
||||
createRecipients(3)
|
||||
val members: List<RecipientId> = recipientList(1, 2, 3)
|
||||
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", members)
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val record: DistributionListRecord? = distributionDatabase.getList(id!!)
|
||||
Assert.assertNotNull(record)
|
||||
Assert.assertEquals(id, record!!.id)
|
||||
Assert.assertEquals("test", record.name)
|
||||
Assert.assertEquals(members, record.members)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMembers_returnsCorrectMembers() {
|
||||
createRecipients(3)
|
||||
val members: List<RecipientId> = recipientList(1, 2, 3)
|
||||
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", members)
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val foundMembers: List<RecipientId> = distributionDatabase.getMembers(id!!)
|
||||
Assert.assertEquals(members, foundMembers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExists_getStoryType_returnsStoryWithReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val storyType = distributionDatabase.getStoryType(id!!)
|
||||
Assert.assertEquals(StoryType.STORY_WITH_REPLIES, storyType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExistsAndMarkedNoReplies_getStoryType_returnsStoryWithoutReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
distributionDatabase.setAllowsReplies(id!!, false)
|
||||
|
||||
val storyType = distributionDatabase.getStoryType(id)
|
||||
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))
|
||||
Assert.fail("Expected an assertion error.")
|
||||
}
|
||||
|
||||
private fun createRecipients(count: Int) {
|
||||
for (i in 0 until count) {
|
||||
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun recipientList(vararg ids: Long): List<RecipientId> {
|
||||
return ids.map { RecipientId.from(it) }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
@@ -52,6 +54,7 @@ class RecipientDatabaseTest_merges {
|
||||
private lateinit var mentionDatabase: MentionDatabase
|
||||
private lateinit var reactionDatabase: ReactionDatabase
|
||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
||||
private lateinit var distributionListDatabase: DistributionListDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
@@ -69,6 +72,7 @@ class RecipientDatabaseTest_merges {
|
||||
mentionDatabase = SignalDatabase.mentions
|
||||
reactionDatabase = SignalDatabase.reactions
|
||||
notificationProfileDatabase = SignalDatabase.notificationProfiles
|
||||
distributionListDatabase = SignalDatabase.distributionLists
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
@@ -120,6 +124,8 @@ class RecipientDatabaseTest_merges {
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
||||
|
||||
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
||||
@@ -201,6 +207,11 @@ class RecipientDatabaseTest_merges {
|
||||
|
||||
assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
|
||||
assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
|
||||
// Distribution List validation
|
||||
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
|
||||
|
||||
assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
private val context: Application
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
|
||||
|
||||
<application android:name=".ApplicationContext"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -386,6 +388,24 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".stories.my.MyStoriesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.settings.StorySettingsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.viewer.StoryViewerActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
@@ -628,6 +648,13 @@
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||
<service android:name=".service.webrtc.AndroidCallConnectionService"
|
||||
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.ConnectionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".components.voice.VoiceNotePlaybackService">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
public final class AppCapabilities {
|
||||
@@ -19,6 +20,6 @@ public final class AppCapabilities {
|
||||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,30 +35,30 @@ import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
@@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||
@@ -78,6 +79,7 @@ import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -91,7 +93,6 @@ import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.security.Security;
|
||||
@@ -188,6 +189,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(EmojiSource::refresh)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
||||
.addNonBlocking(this::ensureProfileUploaded)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
@@ -196,6 +198,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveReleaseChannelJob::enqueue)
|
||||
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
|
||||
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
@@ -123,12 +124,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Rect;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -66,6 +67,7 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
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.sync.DirectoryHelper;
|
||||
@@ -77,6 +79,7 @@ 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.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -96,10 +99,9 @@ import java.util.function.Consumer;
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public final class ContactSelectionListFragment extends LoggingFragment
|
||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||
{
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
@@ -138,18 +140,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private View shadowView;
|
||||
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
||||
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@Nullable private FixedViewsAdapter footerAdapter;
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
private GlideRequests glideRequests;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean hideCount;
|
||||
private boolean canSelectSelf;
|
||||
private GlideRequests glideRequests;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean hideCount;
|
||||
private boolean canSelectSelf;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
@@ -190,6 +193,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof HeaderActionProvider) {
|
||||
headerActionProvider = (HeaderActionProvider) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof HeaderActionProvider) {
|
||||
headerActionProvider = (HeaderActionProvider) getParentFragment();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -243,11 +254,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
shadowView = view.findViewById(R.id.toolbar_shadow);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
@@ -285,6 +299,40 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
final HeaderAction headerAction;
|
||||
if (headerActionProvider != null) {
|
||||
headerAction = headerActionProvider.getHeaderAction();
|
||||
|
||||
headerActionView.setEnabled(true);
|
||||
headerActionView.setText(headerAction.getLabel());
|
||||
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
|
||||
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
|
||||
private final Rect bounds = new Rect();
|
||||
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
|
||||
if (hideLetterHeaders()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int firstPosition = layoutManager.findFirstVisibleItemPosition();
|
||||
if (firstPosition == 0) {
|
||||
View firstChild = recyclerView.getChildAt(0);
|
||||
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
headerActionView.setTranslationY(bounds.top);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
headerActionView.setEnabled(false);
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -491,12 +539,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
fastScroller.setRecyclerView(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (headerActionView.isEnabled() && !hasQueryFilter()) {
|
||||
headerActionView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||
cursorRecyclerViewAdapter.changeCursor(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
@@ -546,6 +601,39 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the caller to submit a list of recipients to be marked selected. Useful for when a screen needs to load preselected
|
||||
* entries in the background before setting them in the adapter.
|
||||
*
|
||||
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
|
||||
*/
|
||||
public void markSelected(@NonNull Set<ShareContact> contacts) {
|
||||
if (contacts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<SelectedContact> toMarkSelected = contacts.stream()
|
||||
.map(contact -> {
|
||||
if (contact.getRecipientId().isPresent()) {
|
||||
return SelectedContact.forRecipientId(contact.getRecipientId().get());
|
||||
} else {
|
||||
return SelectedContact.forPhone(null, contact.getNumber());
|
||||
}
|
||||
})
|
||||
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final SelectedContact selectedContact : toMarkSelected) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
|
||||
}
|
||||
|
||||
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
|
||||
@Override
|
||||
public void onItemClick(ContactSelectionListItem contact) {
|
||||
@@ -575,8 +663,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}, uuid -> {
|
||||
loadingDialog.dismiss();
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||
@@ -668,7 +756,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> addChipForRecipient(resolved, selectedContact));
|
||||
}
|
||||
|
||||
@@ -768,19 +856,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
|
||||
void onBeforeContactSelected(Optional<RecipientId> recipientId, @Nullable String number, Consumer<Boolean> callback);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, @Nullable String number);
|
||||
/**
|
||||
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||
*/
|
||||
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
|
||||
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
||||
public interface OnSelectionLimitReachedListener {
|
||||
void onSuggestedLimitReached(int limit);
|
||||
|
||||
void onHardLimitReached(int limit);
|
||||
}
|
||||
|
||||
public interface ListCallback {
|
||||
void onInvite();
|
||||
|
||||
void onNewGroup(boolean forceV1);
|
||||
}
|
||||
|
||||
@@ -788,6 +882,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
void onBeginScroll();
|
||||
}
|
||||
|
||||
public interface HeaderActionProvider {
|
||||
@NonNull HeaderAction getHeaderAction();
|
||||
}
|
||||
|
||||
public interface AbstractContactsCursorLoaderFactoryProvider {
|
||||
@NonNull AbstractContactsCursorLoader.Factory get();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.AnimRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
@@ -134,13 +135,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,29 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsState;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||
|
||||
@@ -26,13 +36,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
private final MainNavigator navigator = new MainNavigator(this);
|
||||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
|
||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
return intent;
|
||||
@@ -42,9 +53,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
AppStartup.getInstance().onCriticalRenderEventStart();
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
|
||||
setContentView(R.layout.main_activity);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this);
|
||||
|
||||
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
|
||||
|
||||
navigator.onCreate(savedInstanceState);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
@@ -52,12 +68,27 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
handleSignalMeIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||
Transformations.distinctUntilChanged(Transformations.map(conversationListTabsViewModel.getState(), ConversationListTabsState::getTab))
|
||||
.observe(this, tab -> {
|
||||
switch (tab) {
|
||||
case CHATS:
|
||||
getSupportFragmentManager().popBackStack();
|
||||
break;
|
||||
case STORIES:
|
||||
navigator.goToStories();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
updateTabVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intent getIntent() {
|
||||
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
}
|
||||
|
||||
@@ -82,6 +113,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
updateTabVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,6 +132,17 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_secondary));
|
||||
} else {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_primary));
|
||||
navigator.goToChats();
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull MainNavigator getNavigator() {
|
||||
return navigator;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
|
||||
@@ -17,9 +16,12 @@ import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment;
|
||||
|
||||
public class MainNavigator {
|
||||
|
||||
public static final String STORIES_TAG = "STORIES";
|
||||
|
||||
public static final int REQUEST_CONFIG_CHANGES = 901;
|
||||
|
||||
private final MainActivity activity;
|
||||
@@ -82,6 +84,21 @@ public class MainNavigator {
|
||||
.commit();
|
||||
}
|
||||
|
||||
public void goToStories() {
|
||||
if (getFragmentManager().findFragmentByTag(STORIES_TAG) == null) {
|
||||
getFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, new StoriesLandingFragment(), STORIES_TAG)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
public void goToChats() {
|
||||
if (getFragmentManager().findFragmentByTag(STORIES_TAG) != null) {
|
||||
getFragmentManager().popBackStack();
|
||||
}
|
||||
}
|
||||
|
||||
public void goToGroupCreation() {
|
||||
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
||||
}
|
||||
|
||||
@@ -553,6 +553,8 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
cursor = Objects.requireNonNull(data.first);
|
||||
|
||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||
|
||||
int mediaPosition = Objects.requireNonNull(data.second);
|
||||
|
||||
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
|
||||
@@ -565,13 +567,13 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
oldAdapter.setActive(true);
|
||||
}
|
||||
|
||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||
if (oldAdapter == null || restartItem >= 0) {
|
||||
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
||||
mediaPager.setCurrentItem(item);
|
||||
|
||||
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
||||
mediaPager.setCurrentItem(item);
|
||||
|
||||
if (item == 0) {
|
||||
viewPagerListener.onPageSelected(0);
|
||||
if (item == 0) {
|
||||
viewPagerListener.onPageSelected(0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mediaNotAvailable();
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -61,7 +62,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,6 @@ package org.thoughtcrime.securesms;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -62,8 +61,8 @@ public class PassphraseCreateActivity extends PassphraseActivity {
|
||||
passphrase);
|
||||
|
||||
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
|
||||
SignalStore.account().generateAciIdentityKey();
|
||||
SignalStore.account().generatePniIdentityKey();
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary();
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary();
|
||||
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -31,8 +31,8 @@ class TextAvatarDrawable(
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val textSize = Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f)
|
||||
val width = bounds.width()
|
||||
val textSize = Avatars.getTextSizeForLength(context, avatar.text, width * 0.8f, width * 0.45f)
|
||||
val candidates = EmojiProvider.getCandidates(avatar.text)
|
||||
|
||||
textPaint.textSize = textSize
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.thoughtcrime.securesms.avatar.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* AvatarView encapsulating the AvatarImageView and decorations.
|
||||
*/
|
||||
class AvatarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.avatar_view, this)
|
||||
|
||||
isClickable = false
|
||||
}
|
||||
|
||||
private val avatar: AvatarImageView = findViewById<AvatarImageView>(R.id.avatar_image_view).apply {
|
||||
initialize(context, attrs)
|
||||
}
|
||||
|
||||
private val storyRing: View = findViewById(R.id.avatar_story_ring)
|
||||
|
||||
private fun showStoryRing(hasUnreadStory: Boolean) {
|
||||
if (!Stories.isFeatureEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
storyRing.visible = true
|
||||
storyRing.isActivated = hasUnreadStory
|
||||
|
||||
avatar.scaleX = 0.82f
|
||||
avatar.scaleY = 0.82f
|
||||
}
|
||||
|
||||
private fun hideStoryRing() {
|
||||
storyRing.visible = false
|
||||
|
||||
avatar.scaleX = 1f
|
||||
avatar.scaleY = 1f
|
||||
}
|
||||
|
||||
fun setStoryRingFromState(storyViewState: StoryViewState) {
|
||||
when (storyViewState) {
|
||||
StoryViewState.NONE -> hideStoryRing()
|
||||
StoryViewState.UNVIEWED -> showStoryRing(true)
|
||||
StoryViewState.VIEWED -> showStoryRing(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays Note-to-Self
|
||||
*/
|
||||
fun displayChatAvatar(recipient: Recipient) {
|
||||
avatar.setAvatar(recipient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays Note-to-Self
|
||||
*/
|
||||
fun displayChatAvatar(requestManager: GlideRequests, recipient: Recipient, isQuickContactEnabled: Boolean) {
|
||||
avatar.setAvatar(requestManager, recipient, isQuickContactEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays Profile image
|
||||
*/
|
||||
fun displayProfileAvatar(recipient: Recipient) {
|
||||
avatar.setRecipient(recipient)
|
||||
}
|
||||
|
||||
fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
|
||||
avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
|
||||
}
|
||||
|
||||
fun disableQuickContact() {
|
||||
avatar.disableQuickContact()
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,17 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(FullBackupImporter.class);
|
||||
|
||||
private static final String[] TABLES_TO_DROP_FIRST = {
|
||||
"distribution_list_member",
|
||||
"distribution_list",
|
||||
"message_send_log_recipients",
|
||||
"msl_recipient",
|
||||
"msl_message",
|
||||
"reaction",
|
||||
"notification_profile_schedule",
|
||||
"notification_profile_allowed_members"
|
||||
};
|
||||
|
||||
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
|
||||
throws IOException
|
||||
@@ -272,6 +283,10 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
private static void dropAllTables(@NonNull SQLiteDatabase db) {
|
||||
for (String name : TABLES_TO_DROP_FIRST) {
|
||||
db.execSQL("DROP TABLE IF EXISTS " + name);
|
||||
}
|
||||
|
||||
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String name = cursor.getString(0);
|
||||
|
||||
@@ -87,7 +87,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
|
||||
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
@@ -116,7 +116,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
initialize(context, attrs);
|
||||
}
|
||||
|
||||
private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
public void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
setScaleType(ScaleType.CENTER_CROP);
|
||||
|
||||
if (attrs != null) {
|
||||
|
||||
@@ -246,7 +246,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
});
|
||||
|
||||
if (isOutgoing) {
|
||||
dateView.setMaxWidth(ViewUtil.dpToPx(28));
|
||||
dateView.setMaxWidth(ViewUtil.dpToPx(32));
|
||||
} else {
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(this);
|
||||
|
||||
@@ -2,8 +2,9 @@ package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
@@ -12,6 +13,7 @@ import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
@@ -21,9 +23,12 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
|
||||
protected open val peekHeightPercentage: Float = 0.5f
|
||||
|
||||
@StyleRes
|
||||
protected open val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners)
|
||||
setStyle(STYLE_NORMAL, themeResId)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
@@ -38,7 +43,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
|
||||
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
|
||||
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog))
|
||||
val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), R.attr.bottomSheetStyle)
|
||||
dialogBackground.setTint(ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint))
|
||||
|
||||
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
/**
|
||||
* Activity that wraps a given fragment
|
||||
*/
|
||||
abstract class FragmentWrapperActivity : PassphraseRequiredActivity() {
|
||||
|
||||
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
setContentView(R.layout.fragment_container)
|
||||
dynamicTheme.onCreate(this)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_container, getFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getFragment(): Fragment
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dynamicTheme.onResume(this)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@ public class InputPanel extends LinearLayout
|
||||
private ComposeText composeText;
|
||||
private View quickCameraToggle;
|
||||
private View quickAudioToggle;
|
||||
private View buttonToggle;
|
||||
private AnimatingToggle buttonToggle;
|
||||
private SendButton sendButton;
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private ViewGroup composeContainer;
|
||||
@@ -127,6 +128,7 @@ public class InputPanel extends LinearLayout
|
||||
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
|
||||
this.quickAudioToggle = findViewById(R.id.quick_audio_toggle);
|
||||
this.buttonToggle = findViewById(R.id.button_toggle);
|
||||
this.sendButton = findViewById(R.id.send_button);
|
||||
this.recordingContainer = findViewById(R.id.recording_container);
|
||||
this.recordLockCancel = findViewById(R.id.record_cancel);
|
||||
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
|
||||
@@ -185,7 +187,13 @@ public class InputPanel extends LinearLayout
|
||||
: 0;
|
||||
|
||||
this.quoteView.setVisibility(VISIBLE);
|
||||
this.quoteView.measure(0, 0);
|
||||
|
||||
int maxWidth = composeContainer.getWidth();
|
||||
if (quoteView.getLayoutParams() instanceof MarginLayoutParams) {
|
||||
MarginLayoutParams layoutParams = (MarginLayoutParams) quoteView.getLayoutParams();
|
||||
maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
|
||||
}
|
||||
this.quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
|
||||
|
||||
if (quoteAnimator != null) {
|
||||
quoteAnimator.cancel();
|
||||
@@ -475,6 +483,7 @@ public class InputPanel extends LinearLayout
|
||||
voiceNoteDraftView.setDraft(voiceNoteDraft);
|
||||
voiceNoteDraftView.setVisibility(VISIBLE);
|
||||
hideNormalComposeViews();
|
||||
buttonToggle.displayQuick(sendButton);
|
||||
} else {
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
@@ -492,7 +501,7 @@ public class InputPanel extends LinearLayout
|
||||
mediaKeyboard.setAlpha(0f);
|
||||
}
|
||||
|
||||
for (View view : Arrays.asList(composeText, quickCameraToggle, quickAudioToggle, buttonToggle)) {
|
||||
for (View view : Arrays.asList(composeText, quickCameraToggle, quickAudioToggle)) {
|
||||
view.animate().cancel();
|
||||
view.setAlpha(0f);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
|
||||
private var hasShown = false
|
||||
|
||||
protected open val withDim: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -29,7 +31,10 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
|
||||
dialog.window?.setDimAmount(0f)
|
||||
if (!withDim) {
|
||||
dialog.window?.setDimAmount(0f)
|
||||
}
|
||||
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
return dialog
|
||||
|
||||
@@ -29,13 +29,17 @@ public class OutlinedThumbnailView extends ThumbnailView {
|
||||
cornerMask = new CornerMask(this);
|
||||
outliner = new Outliner();
|
||||
|
||||
outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20));
|
||||
int defaultOutlinerColor = ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20);
|
||||
outliner.setColor(defaultOutlinerColor);
|
||||
|
||||
int radius = 0;
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.OutlinedThumbnailView, 0, 0);
|
||||
radius = typedArray.getDimensionPixelOffset(R.styleable.OutlinedThumbnailView_otv_cornerRadius, 0);
|
||||
|
||||
outliner.setStrokeWidth(typedArray.getDimensionPixelSize(R.styleable.OutlinedThumbnailView_otv_strokeWidth, 1));
|
||||
outliner.setColor(typedArray.getColor(R.styleable.OutlinedThumbnailView_otv_strokeColor, defaultOutlinerColor));
|
||||
}
|
||||
|
||||
setRadius(radius);
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -45,9 +44,29 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
private static final String TAG = Log.tag(QuoteView.class);
|
||||
|
||||
private static final int MESSAGE_TYPE_PREVIEW = 0;
|
||||
private static final int MESSAGE_TYPE_OUTGOING = 1;
|
||||
private static final int MESSAGE_TYPE_INCOMING = 2;
|
||||
public enum MessageType {
|
||||
// These codes must match the values for the QuoteView_message_type XML attribute.
|
||||
PREVIEW(0),
|
||||
OUTGOING(1),
|
||||
INCOMING(2),
|
||||
STORY_REPLY(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
MessageType(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
private static @NonNull MessageType fromCode(int code) {
|
||||
for (MessageType value : values()) {
|
||||
if (value.code == code) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unsupported code " + code);
|
||||
}
|
||||
}
|
||||
|
||||
private ViewGroup mainView;
|
||||
private ViewGroup footerView;
|
||||
@@ -66,11 +85,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private SlideDeck attachments;
|
||||
private int messageType;
|
||||
private MessageType messageType;
|
||||
private int largeCornerRadius;
|
||||
private int smallCornerRadius;
|
||||
private CornerMask cornerMask;
|
||||
|
||||
private int thumbHeight;
|
||||
private int thumbWidth;
|
||||
|
||||
public QuoteView(Context context) {
|
||||
super(context);
|
||||
@@ -112,30 +133,25 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
|
||||
|
||||
cornerMask = new CornerMask(this);
|
||||
cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0);
|
||||
int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK);
|
||||
int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK);
|
||||
messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0);
|
||||
messageType = MessageType.fromCode(typedArray.getInt(R.styleable.QuoteView_message_type, 0));
|
||||
typedArray.recycle();
|
||||
|
||||
dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE);
|
||||
dismissView.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
|
||||
|
||||
authorView.setTextColor(primaryColor);
|
||||
bodyView.setTextColor(primaryColor);
|
||||
attachmentNameView.setTextColor(primaryColor);
|
||||
mediaDescriptionText.setTextColor(secondaryColor);
|
||||
missingLinkText.setTextColor(primaryColor);
|
||||
|
||||
if (messageType == MESSAGE_TYPE_PREVIEW) {
|
||||
int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
|
||||
cornerMask.setTopLeftRadius(radius);
|
||||
cornerMask.setTopRightRadius(radius);
|
||||
}
|
||||
}
|
||||
|
||||
setMessageType(messageType);
|
||||
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
@@ -151,6 +167,30 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
if (author != null) author.removeForeverObserver(this);
|
||||
}
|
||||
|
||||
public void setMessageType(@NonNull MessageType messageType) {
|
||||
this.messageType = messageType;
|
||||
|
||||
cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
|
||||
thumbWidth = thumbHeight = getResources().getDimensionPixelSize(R.dimen.quote_thumb_size);
|
||||
|
||||
if (messageType == MessageType.PREVIEW) {
|
||||
int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
|
||||
cornerMask.setTopLeftRadius(radius);
|
||||
cornerMask.setTopRightRadius(radius);
|
||||
} else if (messageType == MessageType.STORY_REPLY) {
|
||||
thumbWidth = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_width);
|
||||
thumbHeight = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_height);
|
||||
}
|
||||
|
||||
mainView.setMinimumHeight(thumbHeight);
|
||||
|
||||
ViewGroup.LayoutParams params = thumbnailView.getLayoutParams();
|
||||
params.height = thumbHeight;
|
||||
params.width = thumbWidth;
|
||||
|
||||
thumbnailView.setLayoutParams(params);
|
||||
}
|
||||
|
||||
public void setQuote(GlideRequests glideRequests,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@@ -172,7 +212,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
setQuoteAttachment(glideRequests, attachments);
|
||||
setQuoteMissingFooter(originalMissing);
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21 && messageType == MESSAGE_TYPE_INCOMING && chatColors != null) {
|
||||
if (Build.VERSION.SDK_INT < 21 && messageType == MessageType.INCOMING && chatColors != null) {
|
||||
this.setBackgroundColor(chatColors.asSingleColor());
|
||||
} else {
|
||||
this.setBackground(null);
|
||||
@@ -208,11 +248,16 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
|
||||
private void setQuoteAuthor(@NonNull Recipient author) {
|
||||
boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
|
||||
boolean preview = messageType == MESSAGE_TYPE_PREVIEW;
|
||||
boolean outgoing = messageType != MessageType.INCOMING;
|
||||
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY;
|
||||
|
||||
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
|
||||
: author.getDisplayName(getContext()));
|
||||
if (messageType == MessageType.STORY_REPLY) {
|
||||
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_your_story)
|
||||
: getContext().getString(R.string.QuoteView_s_story, author.getDisplayName(getContext())));
|
||||
} else {
|
||||
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
|
||||
: author.getDisplayName(getContext()));
|
||||
}
|
||||
|
||||
quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent));
|
||||
mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background));
|
||||
@@ -279,7 +324,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
|
||||
.centerCrop()
|
||||
.override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
|
||||
.override(thumbWidth, thumbHeight)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(thumbnailView);
|
||||
} else if (documentSlide != null){
|
||||
|
||||
@@ -5,10 +5,6 @@ import android.content.res.TypedArray;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.ShapeDrawable;
|
||||
import android.graphics.drawable.shapes.RoundRectShape;
|
||||
import android.graphics.drawable.shapes.Shape;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
@@ -19,7 +15,6 @@ import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
@@ -33,7 +28,6 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
@@ -45,11 +39,8 @@ import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.thoughtcrime.securesms.video.VideoPlayer;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class EmojiImageView extends AppCompatImageView {
|
||||
|
||||
private final boolean forceJumboEmoji;
|
||||
|
||||
public EmojiImageView(Context context) {
|
||||
super(context);
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public EmojiImageView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public EmojiImageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiImageView, 0, 0);
|
||||
forceJumboEmoji = a.getBoolean(R.styleable.EmojiImageView_forceJumbo, false);
|
||||
a.recycle();
|
||||
}
|
||||
|
||||
public void setImageEmoji(CharSequence emoji) {
|
||||
if (isInEditMode()) {
|
||||
setImageResource(R.drawable.ic_emoji);
|
||||
} else {
|
||||
setImageDrawable(EmojiProvider.getEmojiDrawable(getContext(), emoji));
|
||||
setImageDrawable(EmojiProvider.getEmojiDrawable(getContext(), emoji, forceJumboEmoji));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
int lineCount = getLineCount();
|
||||
if (lineCount > maxLines) {
|
||||
int overflowStart = getLayout().getLineStart(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -9,6 +10,8 @@ import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.content.res.TypedArrayUtils;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
@@ -20,8 +23,8 @@ import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemedFragment;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
@@ -34,6 +37,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
private State keyboardState;
|
||||
private KeyboardPagerFragment keyboardPagerFragment;
|
||||
private FragmentManager fragmentManager;
|
||||
private int mediaKeyboardTheme;
|
||||
|
||||
public MediaKeyboard(Context context) {
|
||||
this(context, null);
|
||||
@@ -41,6 +45,12 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
public MediaKeyboard(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.MediaKeyboard);
|
||||
mediaKeyboardTheme = array.getResourceId(R.styleable.MediaKeyboard_media_keyboard_theme, -1);
|
||||
array.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
|
||||
@@ -70,6 +80,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
show();
|
||||
}
|
||||
|
||||
public boolean isInitialised() {
|
||||
return isInitialised;
|
||||
}
|
||||
|
||||
public void show() {
|
||||
if (!isInitialised) initView();
|
||||
|
||||
@@ -122,9 +136,14 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
keyboardState = State.EMOJI_SEARCH;
|
||||
|
||||
EmojiSearchFragment emojiSearchFragment = new EmojiSearchFragment();
|
||||
if (mediaKeyboardTheme != -1) {
|
||||
ThemedFragment.withTheme(emojiSearchFragment, mediaKeyboardTheme);
|
||||
}
|
||||
|
||||
fragmentManager.beginTransaction()
|
||||
.hide(keyboardPagerFragment)
|
||||
.add(R.id.media_keyboard_fragment_container, new EmojiSearchFragment(), EMOJI_SEARCH)
|
||||
.add(R.id.media_keyboard_fragment_container, emojiSearchFragment, EMOJI_SEARCH)
|
||||
.runOnCommit(() -> show(latestKeyboardHeight, true))
|
||||
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out)
|
||||
.commitAllowingStateLoss();
|
||||
@@ -141,6 +160,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
}
|
||||
|
||||
keyboardPagerFragment = new KeyboardPagerFragment();
|
||||
if (mediaKeyboardTheme != -1) {
|
||||
ThemedFragment.withTheme(keyboardPagerFragment, mediaKeyboardTheme);
|
||||
}
|
||||
|
||||
fragmentManager.beginTransaction()
|
||||
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
|
||||
.commitNowAllowingStateLoss();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Tiago Ornelas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.segmentedprogressbar
|
||||
|
||||
/**
|
||||
* Created by Tiago Ornelas on 18/04/2020.
|
||||
* Model that holds the segment state
|
||||
*/
|
||||
class Segment(val animationDurationMillis: Long) {
|
||||
|
||||
var animationProgressPercentage: Float = 0f
|
||||
|
||||
var animationState: AnimationState = AnimationState.IDLE
|
||||
set(value) {
|
||||
animationProgressPercentage = when (value) {
|
||||
AnimationState.ANIMATED -> 1f
|
||||
AnimationState.IDLE -> 0f
|
||||
else -> animationProgressPercentage
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents possible drawing states of the segment
|
||||
*/
|
||||
enum class AnimationState {
|
||||
ANIMATED,
|
||||
ANIMATING,
|
||||
IDLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.segmentedprogressbar
|
||||
|
||||
data class SegmentState(
|
||||
val position: Long,
|
||||
val duration: Long
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Tiago Ornelas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.segmentedprogressbar
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Created by Tiago Ornelas on 18/04/2020.
|
||||
* Represents a segmented progress bar on which, the progress is set by segments
|
||||
* @see Segment
|
||||
* And the progress of each segment is animated based on a set speed
|
||||
*/
|
||||
class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchListener {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* It is common now for devices to run at 60FPS
|
||||
*/
|
||||
val MILLIS_PER_FRAME = TimeUnit.MILLISECONDS.toMillis(17)
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private val corners = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
|
||||
|
||||
/**
|
||||
* Number of total segments to draw
|
||||
*/
|
||||
var segmentCount: Int = resources.getInteger(R.integer.segmentedprogressbar_default_segments_count)
|
||||
set(value) {
|
||||
field = value
|
||||
this.initSegments()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of segment index -> duration in millis. Negative durations
|
||||
* ARE valid but they'll result in a call to SegmentedProgressBarListener#onRequestSegmentProgressPercentage
|
||||
* which should return the current % position for the currently playing item. This helps
|
||||
* to avoid synchronizing the seek bar to playback.
|
||||
*/
|
||||
var segmentDurations: Map<Int, Long> = mapOf()
|
||||
set(value) {
|
||||
field = value
|
||||
this.initSegments()
|
||||
}
|
||||
|
||||
var margin: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_margin)
|
||||
private set
|
||||
var radius: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_corner_radius)
|
||||
private set
|
||||
var segmentStrokeWidth: Int =
|
||||
resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_stroke_width)
|
||||
private set
|
||||
|
||||
var segmentBackgroundColor: Int = Color.WHITE
|
||||
private set
|
||||
var segmentSelectedBackgroundColor: Int =
|
||||
context.getThemeColor(R.attr.colorAccent)
|
||||
private set
|
||||
var segmentStrokeColor: Int = Color.BLACK
|
||||
private set
|
||||
var segmentSelectedStrokeColor: Int = Color.BLACK
|
||||
private set
|
||||
|
||||
var timePerSegmentMs: Long =
|
||||
resources.getInteger(R.integer.segmentedprogressbar_default_time_per_segment_ms).toLong()
|
||||
private set
|
||||
|
||||
private var segments = mutableListOf<Segment>()
|
||||
private val selectedSegment: Segment?
|
||||
get() = segments.firstOrNull { it.animationState == Segment.AnimationState.ANIMATING }
|
||||
private val selectedSegmentIndex: Int
|
||||
get() = segments.indexOf(this.selectedSegment)
|
||||
|
||||
// Drawing
|
||||
val strokeApplicable: Boolean
|
||||
get() = segmentStrokeWidth * 4 <= measuredHeight
|
||||
|
||||
val segmentWidth: Float
|
||||
get() = (measuredWidth - margin * (segmentCount - 1)).toFloat() / segmentCount
|
||||
|
||||
var viewPager: ViewPager? = null
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
set(value) {
|
||||
field = value
|
||||
if (value == null) {
|
||||
viewPager?.removeOnPageChangeListener(this)
|
||||
viewPager?.setOnTouchListener(null)
|
||||
} else {
|
||||
viewPager?.addOnPageChangeListener(this)
|
||||
viewPager?.setOnTouchListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets callbacks for progress bar state changes
|
||||
* @see SegmentedProgressBarListener
|
||||
*/
|
||||
var listener: SegmentedProgressBarListener? = null
|
||||
|
||||
private var lastFrameTimeMillis: Long = 0L
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
|
||||
val typedArray =
|
||||
context.theme.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, 0, 0)
|
||||
|
||||
segmentCount =
|
||||
typedArray.getInt(R.styleable.SegmentedProgressBar_totalSegments, segmentCount)
|
||||
|
||||
margin =
|
||||
typedArray.getDimensionPixelSize(
|
||||
R.styleable.SegmentedProgressBar_segmentMargins,
|
||||
margin
|
||||
)
|
||||
radius =
|
||||
typedArray.getDimensionPixelSize(
|
||||
R.styleable.SegmentedProgressBar_segmentCornerRadius,
|
||||
radius
|
||||
)
|
||||
segmentStrokeWidth =
|
||||
typedArray.getDimensionPixelSize(
|
||||
R.styleable.SegmentedProgressBar_segmentStrokeWidth,
|
||||
segmentStrokeWidth
|
||||
)
|
||||
|
||||
segmentBackgroundColor =
|
||||
typedArray.getColor(
|
||||
R.styleable.SegmentedProgressBar_segmentBackgroundColor,
|
||||
segmentBackgroundColor
|
||||
)
|
||||
segmentSelectedBackgroundColor =
|
||||
typedArray.getColor(
|
||||
R.styleable.SegmentedProgressBar_segmentSelectedBackgroundColor,
|
||||
segmentSelectedBackgroundColor
|
||||
)
|
||||
|
||||
segmentStrokeColor =
|
||||
typedArray.getColor(
|
||||
R.styleable.SegmentedProgressBar_segmentStrokeColor,
|
||||
segmentStrokeColor
|
||||
)
|
||||
segmentSelectedStrokeColor =
|
||||
typedArray.getColor(
|
||||
R.styleable.SegmentedProgressBar_segmentSelectedStrokeColor,
|
||||
segmentSelectedStrokeColor
|
||||
)
|
||||
|
||||
timePerSegmentMs =
|
||||
typedArray.getInt(
|
||||
R.styleable.SegmentedProgressBar_timePerSegment,
|
||||
timePerSegmentMs.toInt()
|
||||
).toLong()
|
||||
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
)
|
||||
|
||||
init {
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
segments.forEachIndexed { index, segment ->
|
||||
val drawingComponents = getDrawingComponents(segment, index)
|
||||
|
||||
when (index) {
|
||||
0 -> {
|
||||
corners.indices.forEach { corners[it] = 0f }
|
||||
corners[0] = radius.toFloat()
|
||||
corners[1] = radius.toFloat()
|
||||
corners[6] = radius.toFloat()
|
||||
corners[7] = radius.toFloat()
|
||||
}
|
||||
segments.lastIndex -> {
|
||||
corners.indices.forEach { corners[it] = 0f }
|
||||
corners[2] = radius.toFloat()
|
||||
corners[3] = radius.toFloat()
|
||||
corners[4] = radius.toFloat()
|
||||
corners[5] = radius.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
drawingComponents.first.forEachIndexed { drawingIndex, rectangle ->
|
||||
when (index) {
|
||||
0, segments.lastIndex -> {
|
||||
path.reset()
|
||||
path.addRoundRect(rectangle, corners, Path.Direction.CW)
|
||||
canvas?.drawPath(path, drawingComponents.second[drawingIndex])
|
||||
}
|
||||
else -> canvas?.drawRect(
|
||||
rectangle,
|
||||
drawingComponents.second[drawingIndex]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFrame(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Start/Resume progress animation
|
||||
*/
|
||||
fun start() {
|
||||
pause()
|
||||
val segment = selectedSegment
|
||||
if (segment == null) {
|
||||
next()
|
||||
} else {
|
||||
isPaused = false
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the animation process
|
||||
*/
|
||||
fun pause() {
|
||||
isPaused = true
|
||||
lastFrameTimeMillis = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the whole animation state and selected segments
|
||||
* !Doesn't restart it!
|
||||
* To restart, call the start() method
|
||||
*/
|
||||
fun reset() {
|
||||
this.segments.map { it.animationState = Segment.AnimationState.IDLE }
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts animation for the following segment
|
||||
*/
|
||||
fun next() {
|
||||
loadSegment(offset = 1, userAction = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts animation for the previous segment
|
||||
*/
|
||||
fun previous() {
|
||||
loadSegment(offset = -1, userAction = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts animation for the current segment
|
||||
*/
|
||||
fun restartSegment() {
|
||||
loadSegment(offset = 0, userAction = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips a number of segments
|
||||
* @param offset number o segments fo skip
|
||||
*/
|
||||
fun skip(offset: Int) {
|
||||
loadSegment(offset = offset, userAction = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets current segment to the
|
||||
* @param position index
|
||||
*/
|
||||
fun setPosition(position: Int) {
|
||||
loadSegment(offset = position - this.selectedSegmentIndex, userAction = true)
|
||||
}
|
||||
|
||||
// Private methods
|
||||
private fun loadSegment(offset: Int, userAction: Boolean) {
|
||||
val oldSegmentIndex = this.segments.indexOf(this.selectedSegment)
|
||||
|
||||
val nextSegmentIndex = oldSegmentIndex + offset
|
||||
|
||||
// Index out of bounds, ignore operation
|
||||
if (userAction && nextSegmentIndex !in 0 until segmentCount) {
|
||||
if (nextSegmentIndex >= segmentCount) {
|
||||
this.listener?.onFinished()
|
||||
} else {
|
||||
restartSegment()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
segments.mapIndexed { index, segment ->
|
||||
if (offset > 0) {
|
||||
if (index < nextSegmentIndex) segment.animationState =
|
||||
Segment.AnimationState.ANIMATED
|
||||
} else if (offset < 0) {
|
||||
if (index > nextSegmentIndex - 1) segment.animationState =
|
||||
Segment.AnimationState.IDLE
|
||||
} else if (offset == 0) {
|
||||
if (index == nextSegmentIndex) segment.animationState = Segment.AnimationState.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
val nextSegment = this.segments.getOrNull(nextSegmentIndex)
|
||||
|
||||
// Handle next segment transition/ending
|
||||
if (nextSegment != null) {
|
||||
pause()
|
||||
nextSegment.animationState = Segment.AnimationState.ANIMATING
|
||||
isPaused = false
|
||||
invalidate()
|
||||
this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex)
|
||||
viewPager?.currentItem = this.selectedSegmentIndex
|
||||
} else {
|
||||
pause()
|
||||
this.listener?.onFinished()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSegmentProgressPercentage(segment: Segment, timeSinceLastFrameMillis: Long): Float {
|
||||
return if (segment.animationDurationMillis > 0) {
|
||||
segment.animationProgressPercentage + timeSinceLastFrameMillis.toFloat() / segment.animationDurationMillis
|
||||
} else {
|
||||
listener?.onRequestSegmentProgressPercentage() ?: 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSegments() {
|
||||
this.segments.clear()
|
||||
segments.addAll(
|
||||
List(segmentCount) {
|
||||
val duration = segmentDurations[it] ?: timePerSegmentMs
|
||||
Segment(duration)
|
||||
}
|
||||
)
|
||||
this.invalidate()
|
||||
reset()
|
||||
}
|
||||
|
||||
private var isPaused = true
|
||||
|
||||
private fun onFrame(frameTimeMillis: Long) {
|
||||
if (isPaused) {
|
||||
return
|
||||
}
|
||||
|
||||
val lastFrameTimeMillis = this.lastFrameTimeMillis
|
||||
|
||||
this.lastFrameTimeMillis = frameTimeMillis
|
||||
|
||||
val selectedSegment = this.selectedSegment
|
||||
if (selectedSegment == null) {
|
||||
loadSegment(offset = 1, userAction = false)
|
||||
} else if (lastFrameTimeMillis > 0L) {
|
||||
val segmentProgressPercentage = getSegmentProgressPercentage(selectedSegment, frameTimeMillis - lastFrameTimeMillis)
|
||||
selectedSegment.animationProgressPercentage = segmentProgressPercentage
|
||||
if (selectedSegment.animationProgressPercentage >= 1f) {
|
||||
loadSegment(offset = 1, userAction = false)
|
||||
} else {
|
||||
this.invalidate()
|
||||
}
|
||||
} else {
|
||||
this.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {}
|
||||
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
this.setPosition(position)
|
||||
}
|
||||
|
||||
override fun onTouch(p0: View?, p1: MotionEvent?): Boolean {
|
||||
when (p1?.action) {
|
||||
MotionEvent.ACTION_DOWN -> pause()
|
||||
MotionEvent.ACTION_UP -> start()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Tiago Ornelas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.segmentedprogressbar
|
||||
|
||||
/**
|
||||
* Created by Tiago Ornelas on 18/04/2020.
|
||||
* Interface to communicate progress events
|
||||
*/
|
||||
interface SegmentedProgressBarListener {
|
||||
/**
|
||||
* Notifies when selected segment changed
|
||||
*/
|
||||
fun onPage(oldPageIndex: Int, newPageIndex: Int)
|
||||
|
||||
/**
|
||||
* Notifies when last segment finished animating
|
||||
*/
|
||||
fun onFinished()
|
||||
|
||||
fun onRequestSegmentProgressPercentage(): Float?
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Tiago Ornelas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.segmentedprogressbar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.TypedValue
|
||||
|
||||
fun Context.getThemeColor(attributeColor: Int): Int {
|
||||
val typedValue = TypedValue()
|
||||
this.theme.resolveAttribute(attributeColor, typedValue, true)
|
||||
return typedValue.data
|
||||
}
|
||||
|
||||
fun SegmentedProgressBar.getDrawingComponents(
|
||||
segment: Segment,
|
||||
segmentIndex: Int
|
||||
): Pair<MutableList<RectF>, MutableList<Paint>> {
|
||||
|
||||
val rectangles = mutableListOf<RectF>()
|
||||
val paints = mutableListOf<Paint>()
|
||||
val segmentWidth = segmentWidth
|
||||
val startBound = segmentIndex * segmentWidth + ((segmentIndex) * margin)
|
||||
val endBound = startBound + segmentWidth
|
||||
val stroke = if (!strokeApplicable) 0f else this.segmentStrokeWidth.toFloat()
|
||||
|
||||
val backgroundPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
color = segmentBackgroundColor
|
||||
}
|
||||
|
||||
val selectedBackgroundPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
color = segmentSelectedBackgroundColor
|
||||
}
|
||||
|
||||
val strokePaint = Paint().apply {
|
||||
color =
|
||||
if (segment.animationState == Segment.AnimationState.IDLE) segmentStrokeColor else segmentSelectedStrokeColor
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = stroke
|
||||
}
|
||||
|
||||
// Background component
|
||||
if (segment.animationState == Segment.AnimationState.ANIMATED) {
|
||||
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
|
||||
paints.add(selectedBackgroundPaint)
|
||||
} else {
|
||||
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
|
||||
paints.add(backgroundPaint)
|
||||
}
|
||||
|
||||
// Progress component
|
||||
if (segment.animationState == Segment.AnimationState.ANIMATING) {
|
||||
rectangles.add(
|
||||
RectF(
|
||||
startBound + stroke,
|
||||
height - stroke,
|
||||
startBound + segment.animationProgressPercentage * segmentWidth,
|
||||
stroke
|
||||
)
|
||||
)
|
||||
paints.add(selectedBackgroundPaint)
|
||||
}
|
||||
|
||||
// Stroke component
|
||||
if (stroke > 0) {
|
||||
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
|
||||
paints.add(strokePaint)
|
||||
}
|
||||
|
||||
return Pair(rectangles, paints)
|
||||
}
|
||||
@@ -25,35 +25,44 @@ abstract class DSLSettingsFragment(
|
||||
protected var layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
|
||||
) : Fragment(layoutId) {
|
||||
|
||||
private var recyclerView: RecyclerView? = null
|
||||
protected var recyclerView: RecyclerView? = null
|
||||
private set
|
||||
|
||||
private var scrollAnimationHelper: OnScrollAnimationHelper? = null
|
||||
|
||||
@CallSuper
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
|
||||
val toolbar: Toolbar? = view.findViewById(R.id.toolbar)
|
||||
val toolbarShadow: View? = view.findViewById(R.id.toolbar_shadow)
|
||||
|
||||
if (titleId != -1) {
|
||||
toolbar.setTitle(titleId)
|
||||
toolbar?.setTitle(titleId)
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
toolbar?.setNavigationOnClickListener {
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
|
||||
if (menuId != -1) {
|
||||
toolbar.inflateMenu(menuId)
|
||||
toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
|
||||
toolbar?.inflateMenu(menuId)
|
||||
toolbar?.setOnMenuItemClickListener { onOptionsItemSelected(it) }
|
||||
}
|
||||
|
||||
if (toolbarShadow != null) {
|
||||
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
|
||||
}
|
||||
|
||||
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
|
||||
val settingsAdapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView = view.findViewById<RecyclerView>(R.id.recycler).apply {
|
||||
edgeEffectFactory = EdgeEffectFactory()
|
||||
layoutManager = layoutManagerProducer(requireContext())
|
||||
adapter = settingsAdapter
|
||||
addOnScrollListener(scrollAnimationHelper!!)
|
||||
|
||||
val helper = scrollAnimationHelper
|
||||
if (helper != null) {
|
||||
addOnScrollListener(helper)
|
||||
}
|
||||
}
|
||||
|
||||
bindAdapter(settingsAdapter)
|
||||
|
||||
@@ -4,8 +4,11 @@ import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@@ -24,6 +27,23 @@ sealed class DSLSettingsIcon {
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromResourceWithBackground(
|
||||
@DrawableRes private val iconId: Int,
|
||||
@ColorRes private val iconTintId: Int,
|
||||
@DrawableRes private val backgroundId: Int,
|
||||
@ColorRes private val backgroundTint: Int,
|
||||
@Px private val insetPx: Int,
|
||||
) : DSLSettingsIcon() {
|
||||
override fun resolve(context: Context): Drawable {
|
||||
return LayerDrawable(
|
||||
arrayOf(
|
||||
FromResource(backgroundId, backgroundTint).resolve(context),
|
||||
InsetDrawable(FromResource(iconId, iconTintId).resolve(context), insetPx, insetPx, insetPx, insetPx)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromDrawable(
|
||||
private val drawable: Drawable
|
||||
) : DSLSettingsIcon() {
|
||||
@@ -33,6 +53,17 @@ sealed class DSLSettingsIcon {
|
||||
abstract fun resolve(context: Context): Drawable
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun from(
|
||||
@DrawableRes iconId: Int,
|
||||
@ColorRes iconTintId: Int,
|
||||
@DrawableRes backgroundId: Int,
|
||||
@ColorRes backgroundTint: Int,
|
||||
@Px insetPx: Int = 0
|
||||
): DSLSettingsIcon {
|
||||
return FromResourceWithBackground(iconId, iconTintId, backgroundId, backgroundTint, insetPx)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId)
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.lock.v2.KbsConstants
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -107,7 +106,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
sectionHeaderPref(R.string.AccountSettingsFragment__account)
|
||||
|
||||
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED && SignalStore.account().isRegistered) {
|
||||
if (Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED && SignalStore.account().isRegistered) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
|
||||
onClick = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
@@ -30,4 +31,12 @@ class ChatsSettingsRepository {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun syncPreferSystemContactPhotos() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
ApplicationDependencies.getJobManager().add(MultiDeviceContactUpdateJob(true))
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
@@ -36,10 +34,9 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
|
||||
|
||||
fun setUseAddressBook(enabled: Boolean) {
|
||||
store.update { it.copy(useAddressBook = enabled) }
|
||||
SignalStore.settings().isPreferSystemContactPhotos = enabled
|
||||
refreshDebouncer.publish { ConversationUtil.refreshRecipientShortcuts() }
|
||||
ApplicationDependencies.getJobManager().add(MultiDeviceContactUpdateJob(true))
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
SignalStore.settings().isPreferSystemContactPhotos = enabled
|
||||
repository.syncPreferSystemContactPhotos()
|
||||
}
|
||||
|
||||
fun setUseSystemEmoji(enabled: Boolean) {
|
||||
|
||||
@@ -198,11 +198,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
sectionHeaderPref(R.string.preferences__internal_network)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_force_censorship),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_force_censorship_description),
|
||||
isChecked = state.forceCensorship,
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_allow_censorship_toggle),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_allow_censorship_toggle_description),
|
||||
isChecked = state.allowCensorshipSetting,
|
||||
onClick = {
|
||||
viewModel.setForceCensorship(!state.forceCensorship)
|
||||
viewModel.setAllowCensorshipSetting(!state.allowCensorshipSetting)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -352,6 +352,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_release_channel)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_release_channel_set_last_version),
|
||||
onClick = {
|
||||
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel),
|
||||
onClick = {
|
||||
@@ -361,9 +368,20 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_release_channel_set_last_version),
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_add_sample_note),
|
||||
onClick = {
|
||||
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
|
||||
viewModel.addSampleReleaseNote()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.ConversationListTabs__stories)
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_disable_stories),
|
||||
isChecked = state.disableStories,
|
||||
onClick = {
|
||||
viewModel.toggleStories()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,17 @@ package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.addStyle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
|
||||
class InternalSettingsRepository(context: Context) {
|
||||
|
||||
@@ -13,4 +23,38 @@ class InternalSettingsRepository(context: Context) {
|
||||
consumer(EmojiFiles.Version.readVersion(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
ApplicationDependencies.getJobManager().runSynchronously(CreateReleaseChannelJob.create(), 5000)
|
||||
|
||||
val title = "Release Note Title"
|
||||
val bodyText = "Release note body. Aren't I awesome?"
|
||||
val body = "$title\n\n$bodyText"
|
||||
val bodyRangeList = BodyRangeList.newBuilder()
|
||||
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
|
||||
|
||||
val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement(
|
||||
recipientId = recipientId,
|
||||
body = body,
|
||||
threadId = threadId,
|
||||
messageRanges = bodyRangeList.build(),
|
||||
image = "https://via.placeholder.com/720x480",
|
||||
imageWidth = 720,
|
||||
imageHeight = 480
|
||||
)
|
||||
|
||||
SignalDatabase.sms.insertBoostRequestMessage(recipientId, threadId)
|
||||
|
||||
if (insertResult != null) {
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(insertResult.messageId)
|
||||
.forEach { ApplicationDependencies.getJobManager().add(AttachmentDownloadJob(insertResult.messageId, it.attachmentId, false)) }
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ data class InternalSettingsState(
|
||||
val gv2ignoreP2PChanges: Boolean,
|
||||
val disableAutoMigrationInitiation: Boolean,
|
||||
val disableAutoMigrationNotification: Boolean,
|
||||
val forceCensorship: Boolean,
|
||||
val allowCensorshipSetting: Boolean,
|
||||
val callingServer: String,
|
||||
val audioProcessingMethod: CallManager.AudioProcessingMethod,
|
||||
val useBuiltInEmojiSet: Boolean,
|
||||
@@ -20,4 +20,5 @@ data class InternalSettingsState(
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
val delayResends: Boolean,
|
||||
val disableStorageService: Boolean,
|
||||
val disableStories: Boolean
|
||||
)
|
||||
|
||||
@@ -66,8 +66,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setForceCensorship(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.FORCE_CENSORSHIP, enabled)
|
||||
fun setAllowCensorshipSetting(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.ALLOW_CENSORSHIP_SETTING, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun toggleStories() {
|
||||
val newState = !SignalStore.storyValues().isFeatureDisabled
|
||||
SignalStore.storyValues().isFeatureDisabled = newState
|
||||
store.update { getState().copy(disableStories = newState) }
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
repository.addSampleReleaseNote()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
store.update { getState().copy(emojiVersion = it.emojiVersion) }
|
||||
}
|
||||
@@ -109,14 +119,15 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
gv2ignoreP2PChanges = SignalStore.internalValues().gv2IgnoreP2PChanges(),
|
||||
disableAutoMigrationInitiation = SignalStore.internalValues().disableGv1AutoMigrateInitiation(),
|
||||
disableAutoMigrationNotification = SignalStore.internalValues().disableGv1AutoMigrateNotification(),
|
||||
forceCensorship = SignalStore.internalValues().forcedCensorship(),
|
||||
allowCensorshipSetting = SignalStore.internalValues().allowChangingCensorshipSetting(),
|
||||
callingServer = SignalStore.internalValues().groupCallingServer(),
|
||||
audioProcessingMethod = SignalStore.internalValues().audioProcessingMethod(),
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
delayResends = SignalStore.internalValues().delayResends(),
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled()
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
|
||||
disableStories = SignalStore.storyValues().isFeatureDisabled
|
||||
)
|
||||
|
||||
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -15,10 +15,12 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import mobi.upod.timedurationpicker.TimeDurationPicker
|
||||
import mobi.upod.timedurationpicker.TimeDurationPickerDialog
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.PassphraseChangeActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -34,7 +36,12 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragmentArgs
|
||||
import org.thoughtcrime.securesms.stories.settings.story.PrivateStoryItem
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil
|
||||
@@ -71,6 +78,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
adapter.registerFactory(ValueClickPreference::class.java, LayoutFactory(::ValueClickPreferenceViewHolder, R.layout.value_click_preference_item))
|
||||
PrivateStoryItem.register(adapter)
|
||||
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val repository = PrivacySettingsRepository()
|
||||
@@ -288,6 +296,55 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
|
||||
summary = DSLSettingsText.from(incognitoSummary),
|
||||
)
|
||||
|
||||
if (Stories.isFeatureAvailable()) {
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.ConversationListTabs__stories)
|
||||
|
||||
if (!SignalStore.storyValues().isFeatureDisabled) {
|
||||
customPref(
|
||||
PrivateStoryItem.RecipientModel(
|
||||
recipient = Recipient.self(),
|
||||
onClick = { findNavController().safeNavigate(R.id.action_privacySettings_to_myStorySettings) }
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(24f).toInt())
|
||||
|
||||
customPref(
|
||||
PrivateStoryItem.NewModel(
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_privacySettings_to_newPrivateStory)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.privateStories.forEach {
|
||||
customPref(
|
||||
PrivateStoryItem.PartialModel(
|
||||
privateStoryItemData = it,
|
||||
onClick = { model ->
|
||||
findNavController().safeNavigate(
|
||||
R.id.action_privacySettings_to_privateStorySettings,
|
||||
PrivateStorySettingsFragmentArgs.Builder(model.privateStoryItemData.id).build().toBundle()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.PrivacySettingsFragment__share_and_view_stories),
|
||||
summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__you_will_no_longer_be_able),
|
||||
isChecked = state.isStoriesEnabled,
|
||||
onClick = {
|
||||
viewModel.setStoriesEnabled(!state.isStoriesEnabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.privacy
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -22,6 +23,12 @@ class PrivacySettingsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getPrivateStories(consumer: (List<DistributionListPartialRecord>) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(SignalDatabase.distributionLists.getCustomListsForUi())
|
||||
}
|
||||
}
|
||||
|
||||
fun syncReadReceiptState() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.privacy
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
|
||||
data class PrivacySettingsState(
|
||||
@@ -15,5 +16,7 @@ data class PrivacySettingsState(
|
||||
val isObsoletePasswordEnabled: Boolean,
|
||||
val isObsoletePasswordTimeoutEnabled: Boolean,
|
||||
val obsoletePasswordTimeout: Int,
|
||||
val universalExpireTimer: Int
|
||||
val universalExpireTimer: Int,
|
||||
val privateStories: List<DistributionListPartialRecord>,
|
||||
val isStoriesEnabled: Boolean
|
||||
)
|
||||
|
||||
@@ -26,6 +26,11 @@ class PrivacySettingsViewModel(
|
||||
store.update { it.copy(blockedCount = count) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
repository.getPrivateStories { privateStories ->
|
||||
store.update { it.copy(privateStories = privateStories) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun setReadReceiptsEnabled(enabled: Boolean) {
|
||||
@@ -83,6 +88,11 @@ class PrivacySettingsViewModel(
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setStoriesEnabled(isStoriesEnabled: Boolean) {
|
||||
SignalStore.storyValues().isFeatureDisabled = !isStoriesEnabled
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
store.update(this::updateState)
|
||||
}
|
||||
@@ -101,12 +111,14 @@ class PrivacySettingsViewModel(
|
||||
isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(ApplicationDependencies.getApplication()),
|
||||
isObsoletePasswordTimeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(ApplicationDependencies.getApplication()),
|
||||
obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication()),
|
||||
universalExpireTimer = SignalStore.settings().universalExpireTimer
|
||||
universalExpireTimer = SignalStore.settings().universalExpireTimer,
|
||||
privateStories = emptyList(),
|
||||
isStoriesEnabled = !SignalStore.storyValues().isFeatureDisabled
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateState(state: PrivacySettingsState): PrivacySettingsState {
|
||||
return getState().copy(blockedCount = state.blockedCount)
|
||||
return getState().copy(blockedCount = state.blockedCount, privateStories = state.privateStories)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -138,6 +138,28 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences_communication__category_censorship_circumvention)
|
||||
|
||||
val censorshipSummaryResId: Int = when (state.censorshipCircumventionState) {
|
||||
CensorshipCircumventionState.AVAILABLE -> R.string.preferences_communication__censorship_circumvention_if_enabled_signal_will_attempt_to_circumvent_censorship
|
||||
CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED -> R.string.preferences_communication__censorship_circumvention_you_have_manually_disabled
|
||||
CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED -> R.string.preferences_communication__censorship_circumvention_has_been_activated_based_on_your_accounts_phone_number
|
||||
CensorshipCircumventionState.UNAVAILABLE_CONNECTED -> R.string.preferences_communication__censorship_circumvention_is_not_necessary_you_are_already_connected
|
||||
CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET -> R.string.preferences_communication__censorship_circumvention_can_only_be_activated_when_connected_to_the_internet
|
||||
}
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_communication__censorship_circumvention),
|
||||
summary = DSLSettingsText.from(censorshipSummaryResId),
|
||||
isChecked = state.censorshipCircumventionEnabled,
|
||||
isEnabled = state.censorshipCircumventionState.available,
|
||||
onClick = {
|
||||
viewModel.setCensorshipCircumventionEnabled(!state.censorshipCircumventionEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences_communication__category_sealed_sender)
|
||||
|
||||
switchPref(
|
||||
|
||||
@@ -3,7 +3,26 @@ package org.thoughtcrime.securesms.components.settings.app.privacy.advanced
|
||||
data class AdvancedPrivacySettingsState(
|
||||
val isPushEnabled: Boolean,
|
||||
val alwaysRelayCalls: Boolean,
|
||||
val censorshipCircumventionState: CensorshipCircumventionState,
|
||||
val censorshipCircumventionEnabled: Boolean,
|
||||
val showSealedSenderStatusIcon: Boolean,
|
||||
val allowSealedSenderFromAnyone: Boolean,
|
||||
val showProgressSpinner: Boolean
|
||||
)
|
||||
|
||||
enum class CensorshipCircumventionState(val available: Boolean) {
|
||||
/** The setting is unavailable because you're connected to the websocket */
|
||||
UNAVAILABLE_CONNECTED(false),
|
||||
|
||||
/** The setting is unavailable because you have no network access at all */
|
||||
UNAVAILABLE_NO_INTERNET(false),
|
||||
|
||||
/** The setting is available, and the user manually disabled it even though we thought they were censored */
|
||||
AVAILABLE_MANUALLY_DISABLED(true),
|
||||
|
||||
/** The setting is available, and it's on because we think the user is censored */
|
||||
AVAILABLE_AUTOMATICALLY_ENABLED(true),
|
||||
|
||||
/** The setting is generically available */
|
||||
AVAILABLE(true),
|
||||
}
|
||||
|
||||
@@ -4,23 +4,40 @@ import android.content.SharedPreferences
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
class AdvancedPrivacySettingsViewModel(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val repository: AdvancedPrivacySettingsRepository
|
||||
) : ViewModel() {
|
||||
) : ViewModel(), NetworkConstraintObserver.NetworkListener {
|
||||
|
||||
private val store = Store(getState())
|
||||
private val singleEvents = SingleLiveEvent<Event>()
|
||||
|
||||
val state: LiveData<AdvancedPrivacySettingsState> = store.stateLiveData
|
||||
val events: LiveData<Event> = singleEvents
|
||||
val disposables: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
init {
|
||||
NetworkConstraintObserver.getInstance(ApplicationDependencies.getApplication()).addListener(this)
|
||||
disposables.add(
|
||||
ApplicationDependencies.getSignalWebSocket().webSocketState
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { refresh() }
|
||||
)
|
||||
}
|
||||
|
||||
fun disablePushMessages() {
|
||||
store.update { getState().copy(showProgressSpinner = true) }
|
||||
@@ -58,21 +75,88 @@ class AdvancedPrivacySettingsViewModel(
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setCensorshipCircumventionEnabled(enabled: Boolean) {
|
||||
SignalStore.settings().setCensorshipCircumventionEnabled(enabled)
|
||||
SignalStore.misc().isServiceReachableWithoutCircumvention = false
|
||||
ApplicationDependencies.resetNetworkConnectionsAfterProxyChange()
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
store.update { getState().copy(showProgressSpinner = it.showProgressSpinner) }
|
||||
}
|
||||
|
||||
private fun getState() = AdvancedPrivacySettingsState(
|
||||
isPushEnabled = SignalStore.account().isRegistered,
|
||||
alwaysRelayCalls = TextSecurePreferences.isTurnOnly(ApplicationDependencies.getApplication()),
|
||||
showSealedSenderStatusIcon = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(
|
||||
ApplicationDependencies.getApplication()
|
||||
),
|
||||
allowSealedSenderFromAnyone = TextSecurePreferences.isUniversalUnidentifiedAccess(
|
||||
ApplicationDependencies.getApplication()
|
||||
),
|
||||
false
|
||||
)
|
||||
override fun onNetworkChanged() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
NetworkConstraintObserver.getInstance(ApplicationDependencies.getApplication()).removeListener(this)
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
private fun getState(): AdvancedPrivacySettingsState {
|
||||
val censorshipCircumventionState = getCensorshipCircumventionState()
|
||||
|
||||
return AdvancedPrivacySettingsState(
|
||||
isPushEnabled = SignalStore.account().isRegistered,
|
||||
alwaysRelayCalls = TextSecurePreferences.isTurnOnly(ApplicationDependencies.getApplication()),
|
||||
censorshipCircumventionState = censorshipCircumventionState,
|
||||
censorshipCircumventionEnabled = getCensorshipCircumventionEnabled(censorshipCircumventionState),
|
||||
showSealedSenderStatusIcon = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(
|
||||
ApplicationDependencies.getApplication()
|
||||
),
|
||||
allowSealedSenderFromAnyone = TextSecurePreferences.isUniversalUnidentifiedAccess(
|
||||
ApplicationDependencies.getApplication()
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCensorshipCircumventionState(): CensorshipCircumventionState {
|
||||
val countryCode: Int = PhoneNumberFormatter.getLocalCountryCode()
|
||||
val isCountryCodeCensoredByDefault: Boolean = ApplicationDependencies.getSignalServiceNetworkAccess().isCountryCodeCensoredByDefault(countryCode)
|
||||
val enabledState: SettingsValues.CensorshipCircumventionEnabled = SignalStore.settings().censorshipCircumventionEnabled
|
||||
val hasInternet: Boolean = NetworkConstraint.isMet(ApplicationDependencies.getApplication())
|
||||
val websocketConnected: Boolean = ApplicationDependencies.getSignalWebSocket().webSocketState.firstOrError().blockingGet() == WebSocketConnectionState.CONNECTED
|
||||
|
||||
return when {
|
||||
SignalStore.internalValues().allowChangingCensorshipSetting() -> {
|
||||
CensorshipCircumventionState.AVAILABLE
|
||||
}
|
||||
isCountryCodeCensoredByDefault && enabledState == SettingsValues.CensorshipCircumventionEnabled.DISABLED -> {
|
||||
CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED
|
||||
}
|
||||
isCountryCodeCensoredByDefault -> {
|
||||
CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED
|
||||
}
|
||||
!hasInternet && enabledState != SettingsValues.CensorshipCircumventionEnabled.ENABLED -> {
|
||||
CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET
|
||||
}
|
||||
websocketConnected && enabledState != SettingsValues.CensorshipCircumventionEnabled.ENABLED -> {
|
||||
CensorshipCircumventionState.UNAVAILABLE_CONNECTED
|
||||
}
|
||||
else -> {
|
||||
CensorshipCircumventionState.AVAILABLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCensorshipCircumventionEnabled(state: CensorshipCircumventionState): Boolean {
|
||||
return when (state) {
|
||||
CensorshipCircumventionState.UNAVAILABLE_CONNECTED,
|
||||
CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET,
|
||||
CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED -> {
|
||||
false
|
||||
}
|
||||
CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED -> {
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
SignalStore.settings().censorshipCircumventionEnabled == SettingsValues.CensorshipCircumventionEnabled.ENABLED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Event {
|
||||
DISABLE_PUSH_FAILED
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
@@ -95,7 +96,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent)
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,14 +140,15 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
|
||||
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
|
||||
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
|
||||
}
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
Log.d(TAG, "Confirmed payment intent.", true)
|
||||
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
|
||||
@@ -147,6 +147,14 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__tax_receipts),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
|
||||
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Locale
|
||||
|
||||
class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.donation_receipt_detail_fragment) {
|
||||
|
||||
private lateinit var progressDialog: ProgressDialog
|
||||
|
||||
private val viewModel: DonationReceiptDetailViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
DonationReceiptDetailViewModel.Factory(
|
||||
DonationReceiptDetailFragmentArgs.fromBundle(requireArguments()).id,
|
||||
DonationReceiptDetailRepository()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
SplashImage.register(adapter)
|
||||
|
||||
val sharePngButton: MaterialButton = requireView().findViewById(R.id.share_png)
|
||||
sharePngButton.isEnabled = false
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.donationReceiptRecord != null) {
|
||||
adapter.submitList(getConfiguration(state.donationReceiptRecord, state.subscriptionName).toMappingModelList())
|
||||
}
|
||||
|
||||
if (state.donationReceiptRecord != null && state.subscriptionName != null) {
|
||||
sharePngButton.isEnabled = true
|
||||
sharePngButton.setOnClickListener {
|
||||
renderPng(state.donationReceiptRecord, state.subscriptionName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPng(record: DonationReceiptRecord, subscriptionName: String) {
|
||||
progressDialog = ProgressDialog(requireContext())
|
||||
progressDialog.show()
|
||||
|
||||
val today: String = DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), System.currentTimeMillis())
|
||||
val amount: String = FiatMoneyUtil.format(resources, record.amount)
|
||||
val type: String = when (record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
||||
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||
}
|
||||
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
|
||||
|
||||
SimpleTask.run(viewLifecycleOwner.lifecycle, {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val view = LayoutInflater
|
||||
.from(requireContext())
|
||||
.inflate(R.layout.donation_receipt_png, null)
|
||||
|
||||
view.findViewById<TextView>(R.id.date).text = today
|
||||
view.findViewById<TextView>(R.id.amount).text = amount
|
||||
view.findViewById<TextView>(R.id.donation_type).text = type
|
||||
view.findViewById<TextView>(R.id.date_paid).text = datePaid
|
||||
|
||||
view.measure(View.MeasureSpec.makeMeasureSpec(DONATION_RECEIPT_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
|
||||
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
|
||||
|
||||
val bitmap = view.drawToBitmap()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream)
|
||||
|
||||
BlobProvider.getInstance()
|
||||
.forData(outputStream.toByteArray())
|
||||
.withMimeType("image/png")
|
||||
.withFileName("Signal-Donation-Receipt.png")
|
||||
.createForSingleSessionInMemory()
|
||||
}, {
|
||||
progressDialog.dismiss()
|
||||
openShareSheet(it)
|
||||
})
|
||||
}
|
||||
|
||||
private fun openShareSheet(uri: Uri) {
|
||||
val mimeType = Intent.normalizeMimeType("image/png")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setStream(uri)
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w(TAG, "No activity existed to share the media.", e)
|
||||
Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(record: DonationReceiptRecord, subscriptionName: String?): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(
|
||||
SplashImage.Model(
|
||||
splashImageResId = R.drawable.ic_signal_logo_type
|
||||
)
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
charSequence = FiatMoneyUtil.format(resources, record.amount),
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_Giant),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from(R.string.DonationReceiptDetailsFragment__donation_type),
|
||||
summary = DSLSettingsText.from(
|
||||
when (record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
||||
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from(R.string.DonationReceiptDetailsFragment__date_paid),
|
||||
summary = record.let { DSLSettingsText.from(DateUtils.formatDateWithYear(Locale.getDefault(), it.timestamp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DONATION_RECEIPT_WIDTH = 1916
|
||||
|
||||
private val TAG = Log.tag(DonationReceiptDetailFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.util.Locale
|
||||
|
||||
class DonationReceiptDetailRepository {
|
||||
fun getSubscriptionLevelName(subscriptionLevel: Int): Single<String> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.getSubscriptionLevels(Locale.getDefault())
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { it.levels[subscriptionLevel.toString()] ?: throw Exception("Subscription level $subscriptionLevel not found") }
|
||||
.map { it.name }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getDonationReceiptRecord(id: Long): Single<DonationReceiptRecord> {
|
||||
return Single.fromCallable<DonationReceiptRecord> {
|
||||
SignalDatabase.donationReceipts.getReceipt(id)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
|
||||
data class DonationReceiptDetailState(
|
||||
val donationReceiptRecord: DonationReceiptRecord? = null,
|
||||
val subscriptionName: String? = null
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
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 org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class DonationReceiptDetailViewModel(id: Long, private val repository: DonationReceiptDetailRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(DonationReceiptDetailState())
|
||||
private val disposables = CompositeDisposable()
|
||||
private var networkDisposable: Disposable
|
||||
private val cachedRecord: Single<DonationReceiptRecord> = repository.getDonationReceiptRecord(id).cache()
|
||||
|
||||
val state: LiveData<DonationReceiptDetailState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
networkDisposable = InternetConnectionObserver
|
||||
.observe()
|
||||
.distinctUntilChanged()
|
||||
.subscribe { isConnected ->
|
||||
if (isConnected) {
|
||||
retry()
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
if (store.state.subscriptionName == null) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
disposables += cachedRecord.subscribe { record ->
|
||||
store.update { it.copy(donationReceiptRecord = record) }
|
||||
}
|
||||
|
||||
disposables += cachedRecord.flatMap {
|
||||
if (it.subscriptionLevel > 0) {
|
||||
repository.getSubscriptionLevelName(it.subscriptionLevel)
|
||||
} else {
|
||||
Single.just("")
|
||||
}
|
||||
}.subscribe { name ->
|
||||
store.update { it.copy(subscriptionName = name) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
networkDisposable.dispose()
|
||||
}
|
||||
|
||||
class Factory(private val id: Long, private val repository: DonationReceiptDetailRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(DonationReceiptDetailViewModel(id, repository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
|
||||
data class DonationReceiptBadge(
|
||||
val type: DonationReceiptRecord.Type,
|
||||
val level: Int,
|
||||
val badge: Badge
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreference
|
||||
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.TextPreference
|
||||
import org.thoughtcrime.securesms.components.settings.TextPreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
|
||||
class DonationReceiptListAdapter(onModelClick: (DonationReceiptListItem.Model) -> Unit) : MappingAdapter(), StickyHeaderDecoration.StickyHeaderAdapter<SectionHeaderPreferenceViewHolder> {
|
||||
|
||||
init {
|
||||
registerFactory(TextPreference::class.java, LayoutFactory({ TextPreferenceViewHolder(it) }, R.layout.dsl_preference_item))
|
||||
DonationReceiptListItem.register(this, onModelClick)
|
||||
}
|
||||
|
||||
override fun getHeaderId(position: Int): Long {
|
||||
return when (val item = getItem(position)) {
|
||||
is DonationReceiptListItem.Model -> item.record.timestamp.toLocalDateTime().year.toLong()
|
||||
else -> StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateHeaderViewHolder(parent: ViewGroup?, position: Int, type: Int): SectionHeaderPreferenceViewHolder {
|
||||
return SectionHeaderPreferenceViewHolder(LayoutInflater.from(parent!!.context).inflate(R.layout.dsl_section_header, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindHeaderViewHolder(viewHolder: SectionHeaderPreferenceViewHolder?, position: Int, type: Int) {
|
||||
viewHolder?.bind(SectionHeaderPreference(DSLSettingsText.from(getHeaderId(position).toString())))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
|
||||
import org.thoughtcrime.securesms.components.ControllableTabLayout
|
||||
|
||||
class DonationReceiptListFragment : Fragment(R.layout.donation_receipt_list_fragment) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val pager: ViewPager2 = view.findViewById(R.id.pager)
|
||||
val tabs: ControllableTabLayout = view.findViewById(R.id.tabs)
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
pager.adapter = DonationReceiptListPageAdapter(this)
|
||||
|
||||
BoldSelectionTabItem.registerListeners(tabs)
|
||||
|
||||
TabLayoutMediator(tabs, pager) { tab, position ->
|
||||
tab.setText(
|
||||
when (position) {
|
||||
0 -> R.string.DonationReceiptListFragment__all
|
||||
1 -> R.string.DonationReceiptListFragment__recurring
|
||||
2 -> R.string.DonationReceiptListFragment__one_time
|
||||
else -> error("Unsupported index $position")
|
||||
}
|
||||
)
|
||||
}.attach()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
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
|
||||
import java.util.Locale
|
||||
|
||||
object DonationReceiptListItem {
|
||||
|
||||
fun register(adapter: MappingAdapter, onClick: (Model) -> Unit) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onClick) }, R.layout.donation_receipt_list_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val record: DonationReceiptRecord,
|
||||
val badge: Badge?
|
||||
) : MappingModel<Model> {
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = record == newItem.record && badge == newItem.badge
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val onClick: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val dateView: TextView = itemView.findViewById(R.id.date)
|
||||
private val typeView: TextView = itemView.findViewById(R.id.type)
|
||||
private val moneyView: TextView = itemView.findViewById(R.id.money)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener { onClick(model) }
|
||||
badgeView.setBadge(model.badge)
|
||||
dateView.text = DateUtils.formatDate(Locale.getDefault(), model.record.timestamp)
|
||||
typeView.setText(
|
||||
when (model.record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
|
||||
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
|
||||
}
|
||||
)
|
||||
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
|
||||
class DonationReceiptListPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 3
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> DonationReceiptListPageFragment.create(null)
|
||||
1 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.RECURRING)
|
||||
2 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.BOOST)
|
||||
else -> error("Unsupported position $position")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.TextPreference
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_page_fragment) {
|
||||
|
||||
private val viewModel: DonationReceiptListPageViewModel by viewModels(factoryProducer = {
|
||||
DonationReceiptListPageViewModel.Factory(type, DonationReceiptListPageRepository())
|
||||
})
|
||||
|
||||
private val sharedViewModel: DonationReceiptListViewModel by viewModels(
|
||||
ownerProducer = { requireParentFragment() },
|
||||
factoryProducer = {
|
||||
DonationReceiptListViewModel.Factory(DonationReceiptListRepository())
|
||||
}
|
||||
)
|
||||
|
||||
private val type: DonationReceiptRecord.Type?
|
||||
get() = requireArguments().getString(ARG_TYPE)?.let { DonationReceiptRecord.Type.fromCode(it) }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = DonationReceiptListAdapter { model ->
|
||||
findNavController().safeNavigate(DonationReceiptListFragmentDirections.actionDonationReceiptListFragmentToDonationReceiptDetailFragment(model.record.id))
|
||||
}
|
||||
|
||||
view.findViewById<RecyclerView>(R.id.recycler).apply {
|
||||
this.adapter = adapter
|
||||
addItemDecoration(StickyHeaderDecoration(adapter, false, true, 0))
|
||||
}
|
||||
|
||||
LiveDataUtil.combineLatest(
|
||||
viewModel.state,
|
||||
sharedViewModel.state
|
||||
) { records, badges ->
|
||||
records.map { DonationReceiptListItem.Model(it, getBadgeForRecord(it, badges)) }
|
||||
}.observe(viewLifecycleOwner) { records ->
|
||||
adapter.submitList(
|
||||
records +
|
||||
TextPreference(
|
||||
title = null,
|
||||
summary = DSLSettingsText.from(
|
||||
R.string.DonationReceiptListFragment__if_you_have,
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.TextAppearance_Signal_Subtitle)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBadgeForRecord(record: DonationReceiptRecord, badges: List<DonationReceiptBadge>): Badge? {
|
||||
return when (record.type) {
|
||||
DonationReceiptRecord.Type.BOOST -> badges.firstOrNull { it.type == DonationReceiptRecord.Type.BOOST }?.badge
|
||||
else -> badges.firstOrNull { it.level == record.subscriptionLevel }?.badge
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_TYPE = "arg_type"
|
||||
|
||||
fun create(type: DonationReceiptRecord.Type?): Fragment {
|
||||
return DonationReceiptListPageFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_TYPE, type?.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
|
||||
class DonationReceiptListPageRepository {
|
||||
fun getRecords(type: DonationReceiptRecord.Type?): Single<List<DonationReceiptRecord>> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.donationReceipts.getReceipts(type)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
|
||||
class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, repository: DonationReceiptListPageRepository) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val internalState = MutableLiveData<List<DonationReceiptRecord>>()
|
||||
|
||||
val state: LiveData<List<DonationReceiptRecord>> = internalState
|
||||
|
||||
init {
|
||||
disposables += repository.getRecords(type)
|
||||
.subscribe { records ->
|
||||
internalState.postValue(records)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val type: DonationReceiptRecord.Type?, private val repository: DonationReceiptListPageRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(DonationReceiptListPageViewModel(type, repository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.util.Locale
|
||||
|
||||
class DonationReceiptListRepository {
|
||||
fun getBadges(): Single<List<DonationReceiptBadge>> {
|
||||
val boostBadges: Single<List<DonationReceiptBadge>> = ApplicationDependencies.getDonationsService().getBoostBadge(Locale.getDefault())
|
||||
.map { response ->
|
||||
if (response.result.isPresent) {
|
||||
listOf(DonationReceiptBadge(DonationReceiptRecord.Type.BOOST, -1, Badges.fromServiceBadge(response.result.get())))
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val subBadges: Single<List<DonationReceiptBadge>> = ApplicationDependencies.getDonationsService().getSubscriptionLevels(Locale.getDefault())
|
||||
.map { response ->
|
||||
if (response.result.isPresent) {
|
||||
response.result.get().levels.map {
|
||||
DonationReceiptBadge(
|
||||
level = it.key.toInt(),
|
||||
badge = Badges.fromServiceBadge(it.value.badge),
|
||||
type = DonationReceiptRecord.Type.RECURRING
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
return boostBadges.zipWith(subBadges) { a, b -> a + b }.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
|
||||
class DonationReceiptListViewModel(private val repository: DonationReceiptListRepository) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val internalState = MutableLiveData<List<DonationReceiptBadge>>(emptyList())
|
||||
private var networkDisposable: Disposable
|
||||
|
||||
val state: LiveData<List<DonationReceiptBadge>> = internalState
|
||||
|
||||
init {
|
||||
networkDisposable = InternetConnectionObserver
|
||||
.observe()
|
||||
.distinctUntilChanged()
|
||||
.subscribe { isConnected ->
|
||||
if (isConnected) {
|
||||
retry()
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
if (internalState.value?.isEmpty() == true) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += repository.getBadges().subscribe { badges ->
|
||||
internalState.postValue(badges)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
networkDisposable.dispose()
|
||||
}
|
||||
|
||||
class Factory(private val repository: DonationReceiptListRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(DonationReceiptListViewModel(repository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,8 +266,12 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
AvatarPreference.Model(
|
||||
recipient = state.recipient,
|
||||
storyViewState = state.storyViewState,
|
||||
onAvatarClick = { avatar ->
|
||||
if (!state.recipient.isSelf) {
|
||||
// startActivity(StoryViewerActivity.createIntent(requireContext(), state.recipient.id))
|
||||
|
||||
// TODO [stories] -- If recipient has a story, go to story viewer.
|
||||
requireActivity().apply {
|
||||
startActivity(
|
||||
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
@@ -13,6 +15,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
@@ -44,6 +47,14 @@ class ConversationSettingsRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun getStoryViewState(groupId: GroupId): Observable<StoryViewState> {
|
||||
return Observable.fromCallable {
|
||||
SignalDatabase.recipients.getByGroupId(groupId)
|
||||
}.flatMap {
|
||||
StoryViewState.getForRecipientId(it.get())
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(SignalDatabase.threads.getThreadIdIfExistsFor(recipientId))
|
||||
@@ -65,7 +76,11 @@ class ConversationSettingsRepository(
|
||||
|
||||
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId).orNull())
|
||||
if (SignalStore.account().aci != null && SignalStore.account().pni != null) {
|
||||
consumer(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId).orNull())
|
||||
} else {
|
||||
consumer(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ConversationSettingsState(
|
||||
val threadId: Long = -1,
|
||||
val storyViewState: StoryViewState = StoryViewState.NONE,
|
||||
val recipient: Recipient = Recipient.UNKNOWN,
|
||||
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
|
||||
val disappearingMessagesLifespan: Int = 0,
|
||||
|
||||
@@ -6,12 +6,15 @@ import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -46,6 +49,8 @@ sealed class ConversationSettingsViewModel(
|
||||
val state: LiveData<ConversationSettingsState> = store.stateLiveData
|
||||
val events: LiveData<ConversationSettingsEvent> = internalEvents
|
||||
|
||||
protected val disposable = CompositeDisposable()
|
||||
|
||||
init {
|
||||
val threadId: LiveData<Long> = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId })
|
||||
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
|
||||
@@ -105,6 +110,7 @@ sealed class ConversationSettingsViewModel(
|
||||
cleared = true
|
||||
openedMediaCursors.forEach { it.ensureClosed() }
|
||||
store.clear()
|
||||
disposable.clear()
|
||||
}
|
||||
|
||||
private fun Cursor?.ensureClosed() {
|
||||
@@ -126,6 +132,10 @@ sealed class ConversationSettingsViewModel(
|
||||
private val liveRecipient = Recipient.live(recipientId)
|
||||
|
||||
init {
|
||||
disposable += StoryViewState.getForRecipientId(recipientId).subscribe { storyViewState ->
|
||||
store.update { it.copy(storyViewState = storyViewState) }
|
||||
}
|
||||
|
||||
store.update(liveRecipient.liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
@@ -240,6 +250,10 @@ sealed class ConversationSettingsViewModel(
|
||||
private val liveGroup = LiveGroup(groupId)
|
||||
|
||||
init {
|
||||
disposable += repository.getStoryViewState(groupId).subscribe { storyViewState ->
|
||||
store.update { it.copy(storyViewState = storyViewState) }
|
||||
}
|
||||
|
||||
val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a }
|
||||
store.update(recipientAndIsActive) { (recipient, isActive), state ->
|
||||
state.copy(
|
||||
|
||||
@@ -195,6 +195,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
colorize("SenderKey", recipient.senderKeyCapability),
|
||||
", ",
|
||||
colorize("ChangeNumber", recipient.changeNumberCapability),
|
||||
", ",
|
||||
colorize("Stories", recipient.storiesCapability),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.view.AvatarView
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
@@ -26,6 +27,7 @@ object AvatarPreference {
|
||||
|
||||
class Model(
|
||||
val recipient: Recipient,
|
||||
val storyViewState: StoryViewState,
|
||||
val onAvatarClick: (View) -> Unit,
|
||||
val onBadgeClick: (Badge) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
@@ -39,7 +41,7 @@ object AvatarPreference {
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
|
||||
private val avatar: AvatarView = itemView.findViewById<AvatarView>(R.id.bio_preference_avatar).apply {
|
||||
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
|
||||
}
|
||||
|
||||
@@ -63,7 +65,8 @@ object AvatarPreference {
|
||||
}
|
||||
}
|
||||
|
||||
avatar.setAvatar(model.recipient)
|
||||
avatar.setStoryRingFromState(model.storyViewState)
|
||||
avatar.displayChatAvatar(model.recipient)
|
||||
avatar.disableQuickContact()
|
||||
avatar.setOnClickListener { model.onAvatarClick(avatar) }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ object LargeIconClickPreference {
|
||||
class Model(
|
||||
override val title: DSLSettingsText?,
|
||||
override val icon: DSLSettingsIcon,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>()
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
|
||||
}
|
||||
|
||||
class DSLConfiguration {
|
||||
private val children = arrayListOf<PreferenceModel<*>>()
|
||||
private val children = arrayListOf<MappingModel<*>>()
|
||||
|
||||
fun customPref(customPreference: PreferenceModel<*>) {
|
||||
fun customPref(customPreference: MappingModel<*>) {
|
||||
children.add(customPreference)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
public void onResume(@NonNull LifecycleOwner owner) {
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
|
||||
@@ -204,6 +205,19 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
public void onPlayerError(@NonNull PlaybackException error) {
|
||||
Log.w(TAG, "ExoPlayer error occurred:", error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioAttributesChanged(AudioAttributes audioAttributes) {
|
||||
final int stream;
|
||||
if (audioAttributes.usage == C.USAGE_VOICE_COMMUNICATION) {
|
||||
stream = AudioManager.STREAM_VOICE_CALL;
|
||||
} else {
|
||||
stream = AudioManager.STREAM_MUSIC;
|
||||
}
|
||||
|
||||
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
|
||||
mediaSession.setPlaybackToLocal(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
|
||||
|
||||
@@ -312,26 +312,26 @@ data class CallParticipantsState(
|
||||
@PluralsRes multipleParticipants: Int,
|
||||
members: List<GroupMemberEntry.FullMember>
|
||||
): String {
|
||||
val membersWithoutYou: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf }
|
||||
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
|
||||
|
||||
return when (membersWithoutYou.size) {
|
||||
return when (eligibleMembers.size) {
|
||||
0 -> ""
|
||||
1 -> context.getString(
|
||||
oneParticipant,
|
||||
membersWithoutYou[0].member.getShortDisplayName(context)
|
||||
eligibleMembers[0].member.getShortDisplayName(context)
|
||||
)
|
||||
2 -> context.getString(
|
||||
twoParticipants,
|
||||
membersWithoutYou[0].member.getShortDisplayName(context),
|
||||
membersWithoutYou[1].member.getShortDisplayName(context)
|
||||
eligibleMembers[0].member.getShortDisplayName(context),
|
||||
eligibleMembers[1].member.getShortDisplayName(context)
|
||||
)
|
||||
else -> {
|
||||
val others = membersWithoutYou.size - 2
|
||||
val others = eligibleMembers.size - 2
|
||||
context.resources.getQuantityString(
|
||||
multipleParticipants,
|
||||
others,
|
||||
membersWithoutYou[0].member.getShortDisplayName(context),
|
||||
membersWithoutYou[1].member.getShortDisplayName(context),
|
||||
eligibleMembers[0].member.getShortDisplayName(context),
|
||||
eligibleMembers[1].member.getShortDisplayName(context),
|
||||
others
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,14 +115,14 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
|
||||
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
|
||||
|
||||
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
|
||||
if (recipientSnapshot != null && !recipientSnapshot.isResolving() && !recipientSnapshot.isMyStory()) {
|
||||
contactName = recipientSnapshot.getDisplayName(getContext());
|
||||
name = contactName;
|
||||
} else if (recipient != null) {
|
||||
name = "";
|
||||
}
|
||||
|
||||
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
|
||||
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered() || recipientSnapshot.isDistributionList()) {
|
||||
smsTag.setVisibility(GONE);
|
||||
} else {
|
||||
smsTag.setVisibility(VISIBLE);
|
||||
@@ -131,6 +131,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
|
||||
this.contactPhotoImage.setAvatar(glideRequests, null, false);
|
||||
setText(null, type, name, number, label, about);
|
||||
} else if (recipientSnapshot.isMyStory()) {
|
||||
this.contactPhotoImage.setRecipient(Recipient.self(), false);
|
||||
setText(recipientSnapshot, type, name, number, label, about);
|
||||
} else {
|
||||
this.contactPhotoImage.setAvatar(glideRequests, recipientSnapshot, false);
|
||||
setText(recipientSnapshot, type, name, number, label, about);
|
||||
@@ -180,6 +183,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
this.nameView.setEnabled(true);
|
||||
this.labelView.setText(label);
|
||||
this.labelView.setVisibility(View.VISIBLE);
|
||||
} else if (recipient != null && recipient.isDistributionList()) {
|
||||
this.numberView.setText(getViewerCount(number));
|
||||
this.labelView.setVisibility(View.GONE);
|
||||
} else {
|
||||
this.numberView.setText(!Util.isEmpty(about) ? about : number);
|
||||
this.nameView.setEnabled(true);
|
||||
@@ -212,6 +218,11 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_members, memberCount, memberCount);
|
||||
}
|
||||
|
||||
private String getViewerCount(@NonNull String number) {
|
||||
int viewerCount = Integer.parseInt(number);
|
||||
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_viewers, viewerCount, viewerCount);
|
||||
}
|
||||
|
||||
public @Nullable LiveRecipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
@@ -234,13 +245,18 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
contactNumber = recipient.getGroupId().get().toString();
|
||||
} else if (recipient.hasE164()) {
|
||||
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
|
||||
} else {
|
||||
} else if (!recipient.isDistributionList()) {
|
||||
contactNumber = recipient.getEmail().or("");
|
||||
}
|
||||
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
if (recipient.isMyStory()) {
|
||||
contactPhotoImage.setRecipient(Recipient.self(), false);
|
||||
} else {
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
}
|
||||
|
||||
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
|
||||
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
|
||||
smsTag.setVisibility(recipient.isRegistered() || recipient.isDistributionList() ? GONE : VISIBLE);
|
||||
badge.setBadgeFromRecipient(recipient);
|
||||
} else {
|
||||
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
|
||||
|
||||
@@ -27,8 +27,10 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
|
||||
@@ -55,13 +57,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
public static final int FLAG_HIDE_NEW = 1 << 6;
|
||||
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
|
||||
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
|
||||
public static final int FLAG_STORIES = 1 << 9;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
|
||||
}
|
||||
|
||||
private static final int RECENT_CONVERSATION_MAX = 25;
|
||||
|
||||
private final int mode;
|
||||
private final boolean recents;
|
||||
private final int mode;
|
||||
private final boolean recents;
|
||||
|
||||
private final ContactRepository contactRepository;
|
||||
|
||||
@@ -85,6 +88,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
addRecentGroupsSection(cursorList);
|
||||
addGroupsSection(cursorList);
|
||||
} else {
|
||||
addStoriesSection(cursorList);
|
||||
addRecentsSection(cursorList);
|
||||
addContactsSection(cursorList);
|
||||
if (addGroupsAfterContacts(mode)) {
|
||||
@@ -163,6 +167,19 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
}
|
||||
}
|
||||
|
||||
private void addStoriesSection(@NonNull List<Cursor> cursorList) {
|
||||
if (!Stories.isFeatureEnabled() ||!storiesEnabled(mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor stories = getStoriesCursor();
|
||||
|
||||
if (stories.getCount() > 0) {
|
||||
cursorList.add(ContactsCursorRows.forStoriesHeader(getContext()));
|
||||
cursorList.add(stories);
|
||||
}
|
||||
}
|
||||
|
||||
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
|
||||
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(getFilter())) {
|
||||
cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext()));
|
||||
@@ -223,6 +240,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
return groupContacts;
|
||||
}
|
||||
|
||||
private Cursor getStoriesCursor() {
|
||||
MatrixCursor distributionListsCursor = ContactsCursorRows.createMatrixCursor();
|
||||
List<DistributionListPartialRecord> distributionLists = SignalDatabase.distributionLists().getAllListsForContactSelectionUi(null, true);
|
||||
for (final DistributionListPartialRecord distributionList : distributionLists) {
|
||||
distributionListsCursor.addRow(ContactsCursorRows.forDistributionList(distributionList));
|
||||
}
|
||||
|
||||
return distributionListsCursor;
|
||||
}
|
||||
|
||||
private Cursor getNewNumberCursor() {
|
||||
return ContactsCursorRows.forNewNumber(getUnknownContactTitle(), getFilter());
|
||||
}
|
||||
@@ -293,16 +320,20 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
return flagSet(mode, DisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
}
|
||||
|
||||
private static boolean storiesEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_STORIES);
|
||||
}
|
||||
|
||||
private static boolean flagSet(int mode, int flag) {
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
|
||||
public static class Factory implements AbstractContactsCursorLoader.Factory {
|
||||
|
||||
private final Context context;
|
||||
private final int displayMode;
|
||||
private final String cursorFilter;
|
||||
private final boolean displayRecents;
|
||||
private final Context context;
|
||||
private final int displayMode;
|
||||
private final String cursorFilter;
|
||||
private final boolean displayRecents;
|
||||
|
||||
public Factory(Context context, int displayMode, String cursorFilter, boolean displayRecents) {
|
||||
this.context = context;
|
||||
|
||||
@@ -9,8 +9,11 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
/**
|
||||
* Helper utility for generating cursors and cursor rows for subclasses of {@link AbstractContactsCursorLoader}.
|
||||
@@ -83,6 +86,16 @@ public final class ContactsCursorRows {
|
||||
""};
|
||||
}
|
||||
|
||||
public static @NonNull Object[] forDistributionList(@NonNull DistributionListPartialRecord distributionListPartialRecord) {
|
||||
return new Object[]{ distributionListPartialRecord.getRecipientId().serialize(),
|
||||
distributionListPartialRecord.getName(),
|
||||
SignalDatabase.distributionLists().getMemberCount(distributionListPartialRecord.getId()),
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"",
|
||||
ContactRepository.NORMAL_TYPE,
|
||||
""};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a row for a contacts cursor for a new number the user is entering or has entered.
|
||||
*/
|
||||
@@ -117,6 +130,10 @@ public final class ContactsCursorRows {
|
||||
return matrixCursor;
|
||||
}
|
||||
|
||||
public static @NonNull MatrixCursor forStoriesHeader(@NonNull Context context) {
|
||||
return forHeader(context.getString(R.string.ContactsCursorLoader_my_stories));
|
||||
}
|
||||
|
||||
public static @NonNull MatrixCursor forUsernameSearchHeader(@NonNull Context context) {
|
||||
return forHeader(context.getString(R.string.ContactsCursorLoader_username_search));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* An action which can be attached to the first item in the list, but only if that item is a divider.
|
||||
*/
|
||||
class HeaderAction(@param:StringRes val label: Int, @param:DrawableRes val icon: Int, val action: Runnable) {
|
||||
constructor(@StringRes label: Int, action: Runnable) : this(label, 0, action) {}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public final class SelectedContact {
|
||||
return new SelectedContact(recipientId, null, username);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
|
||||
return new SelectedContact(recipientId, null, null);
|
||||
}
|
||||
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
|
||||
this.recipientId = recipientId;
|
||||
this.number = number;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction
|
||||
|
||||
/**
|
||||
* A strongly typed descriptor of how a given list of contacts should be formatted
|
||||
*/
|
||||
class ContactSearchConfiguration private constructor(
|
||||
val query: String?,
|
||||
val sections: List<Section>
|
||||
) {
|
||||
sealed class Section(val sectionKey: SectionKey) {
|
||||
|
||||
abstract val includeHeader: Boolean
|
||||
open val headerAction: HeaderAction? = null
|
||||
abstract val expandConfig: ExpandConfig?
|
||||
|
||||
/**
|
||||
* Distribution lists and group stories.
|
||||
*/
|
||||
data class Stories(
|
||||
val groupStories: Set<ContactSearchData.Story> = emptySet(),
|
||||
override val includeHeader: Boolean,
|
||||
override val headerAction: HeaderAction? = null,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.STORIES)
|
||||
|
||||
/**
|
||||
* Recent contacts
|
||||
*/
|
||||
data class Recents(
|
||||
val limit: Int = 25,
|
||||
val groupsOnly: Boolean = false,
|
||||
val includeInactiveGroups: Boolean = false,
|
||||
val includeGroupsV1: Boolean = false,
|
||||
val includeSms: Boolean = false,
|
||||
override val includeHeader: Boolean,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.RECENTS)
|
||||
|
||||
/**
|
||||
* 1:1 Recipients
|
||||
*/
|
||||
data class Individuals(
|
||||
val includeSelf: Boolean,
|
||||
val transportType: TransportType,
|
||||
override val includeHeader: Boolean,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.INDIVIDUALS)
|
||||
|
||||
/**
|
||||
* Group Recipients
|
||||
*/
|
||||
data class Groups(
|
||||
val includeMms: Boolean = false,
|
||||
val includeV1: Boolean = false,
|
||||
val includeInactive: Boolean = false,
|
||||
val returnAsGroupStories: Boolean = false,
|
||||
override val includeHeader: Boolean,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.GROUPS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a given section. Useful for labeling sections and managing expansion state.
|
||||
*/
|
||||
enum class SectionKey {
|
||||
STORIES,
|
||||
RECENTS,
|
||||
INDIVIDUALS,
|
||||
GROUPS
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes how a given section can be expanded.
|
||||
*/
|
||||
data class ExpandConfig(
|
||||
val isExpanded: Boolean,
|
||||
val maxCountWhenNotExpanded: Int = 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Network transport type for individual recipients.
|
||||
*/
|
||||
enum class TransportType {
|
||||
PUSH,
|
||||
SMS,
|
||||
ALL
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* DSL Style builder function. Example:
|
||||
*
|
||||
* ```
|
||||
* val configuration = ContactSearchConfiguration.build {
|
||||
* query = "My Query"
|
||||
* addSection(Recents(...))
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fun build(builderFunction: Builder.() -> Unit): ContactSearchConfiguration {
|
||||
return ConfigurationBuilder().let {
|
||||
it.builderFunction()
|
||||
it.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal builder class with build method.
|
||||
*/
|
||||
private class ConfigurationBuilder : Builder {
|
||||
private val sections: MutableList<Section> = mutableListOf()
|
||||
|
||||
override var query: String? = null
|
||||
|
||||
override fun addSection(section: Section) {
|
||||
sections.add(section)
|
||||
}
|
||||
|
||||
fun build(): ContactSearchConfiguration {
|
||||
return ContactSearchConfiguration(query, sections)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed Builder interface without build method.
|
||||
*/
|
||||
interface Builder {
|
||||
var query: String?
|
||||
fun addSection(section: Section)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Represents the data backed by a ContactSearchKey
|
||||
*/
|
||||
sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
||||
/**
|
||||
* A row displaying a story.
|
||||
*
|
||||
* Note that if the recipient is a group, it's participant list size is used instead of viewerCount.
|
||||
*/
|
||||
data class Story(val recipient: Recipient, val viewerCount: Int) : ContactSearchData(ContactSearchKey.Story(recipient.id))
|
||||
|
||||
/**
|
||||
* A row displaying a known recipient.
|
||||
*/
|
||||
data class KnownRecipient(val recipient: Recipient) : ContactSearchData(ContactSearchKey.KnownRecipient(recipient.id))
|
||||
|
||||
/**
|
||||
* A row containing a title for a given section
|
||||
*/
|
||||
class Header(
|
||||
val sectionKey: ContactSearchConfiguration.SectionKey,
|
||||
val action: HeaderAction?
|
||||
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
|
||||
|
||||
/**
|
||||
* A row which the user can click to view all entries for a given section.
|
||||
*/
|
||||
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.view.View
|
||||
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.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.MappingModelList
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Mapping Models and View Holders for ContactSearchData
|
||||
*/
|
||||
object ContactSearchItems {
|
||||
fun register(
|
||||
mappingAdapter: MappingAdapter,
|
||||
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
|
||||
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
|
||||
expandListener: (ContactSearchData.Expand) -> Unit
|
||||
) {
|
||||
mappingAdapter.registerFactory(
|
||||
StoryModel::class.java,
|
||||
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
RecipientModel::class.java,
|
||||
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
HeaderModel::class.java,
|
||||
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
ExpandModel::class.java,
|
||||
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>): MappingModelList {
|
||||
return MappingModelList(
|
||||
contactSearchData.filterNotNull().map {
|
||||
when (it) {
|
||||
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey))
|
||||
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey))
|
||||
is ContactSearchData.Expand -> ExpandModel(it)
|
||||
is ContactSearchData.Header -> HeaderModel(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Story Model
|
||||
*/
|
||||
private class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean) : MappingModel<StoryModel> {
|
||||
|
||||
override fun areItemsTheSame(newItem: StoryModel): Boolean {
|
||||
return newItem.story == story
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: StoryModel): Boolean {
|
||||
return story.recipient.hasSameContent(newItem.story.recipient) && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: StoryModel): Any? {
|
||||
return if (story.recipient.hasSameContent(newItem.story.recipient) && newItem.isSelected != isSelected) {
|
||||
0
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class StoryViewHolder(itemView: View, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, 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
|
||||
|
||||
override fun bindNumberField(model: StoryModel) {
|
||||
number.visible = true
|
||||
|
||||
val count = if (model.story.recipient.isGroup) {
|
||||
model.story.recipient.participants.size
|
||||
} else {
|
||||
model.story.viewerCount
|
||||
}
|
||||
|
||||
number.text = context.resources.getQuantityString(R.plurals.SelectViewersFragment__d_viewers, count, count)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient model
|
||||
*/
|
||||
private class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean) : MappingModel<RecipientModel> {
|
||||
|
||||
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
|
||||
return newItem.knownRecipient == knownRecipient
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
|
||||
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: RecipientModel): Any? {
|
||||
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
|
||||
0
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class KnownRecipientViewHolder(itemView: View, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Base Recipient View Holder
|
||||
*/
|
||||
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, val onClick: (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)
|
||||
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
|
||||
protected val name: TextView = itemView.findViewById(R.id.name)
|
||||
protected val number: TextView = itemView.findViewById(R.id.number)
|
||||
protected val label: TextView = itemView.findViewById(R.id.label)
|
||||
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
|
||||
|
||||
override fun bind(model: T) {
|
||||
checkbox.isChecked = isSelected(model)
|
||||
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getRecipient(model).isSelf) {
|
||||
name.setText(R.string.note_to_self)
|
||||
} else {
|
||||
name.text = getRecipient(model).getDisplayName(context)
|
||||
}
|
||||
|
||||
avatar.setAvatar(getRecipient(model))
|
||||
badge.setBadgeFromRecipient(getRecipient(model))
|
||||
|
||||
bindNumberField(model)
|
||||
bindLabelField(model)
|
||||
bindSmsTagField(model)
|
||||
}
|
||||
|
||||
protected open fun bindNumberField(model: T) {
|
||||
number.visible = getRecipient(model).isGroup
|
||||
if (getRecipient(model).isGroup) {
|
||||
val members = getRecipient(model).participants.size
|
||||
number.text = context.resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun bindLabelField(model: T) {
|
||||
label.visible = false
|
||||
}
|
||||
|
||||
protected open fun bindSmsTagField(model: T) {
|
||||
smsTag.visible = false
|
||||
}
|
||||
|
||||
abstract fun isSelected(model: T): Boolean
|
||||
abstract fun getData(model: T): D
|
||||
abstract fun getRecipient(model: T): Recipient
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for section headers
|
||||
*/
|
||||
private class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
|
||||
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
|
||||
return header.sectionKey == newItem.header.sectionKey
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
|
||||
return areItemsTheSame(newItem) &&
|
||||
header.action?.icon == newItem.header.action?.icon &&
|
||||
header.action?.label == newItem.header.action?.label
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for section headers
|
||||
*/
|
||||
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
|
||||
|
||||
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
|
||||
private val headerActionView: TextView = itemView.findViewById(R.id.section_header_action)
|
||||
|
||||
override fun bind(model: HeaderModel) {
|
||||
headerTextView.setText(
|
||||
when (model.header.sectionKey) {
|
||||
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
|
||||
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
|
||||
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
|
||||
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
|
||||
}
|
||||
)
|
||||
|
||||
if (model.header.action != null) {
|
||||
headerActionView.visible = true
|
||||
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(model.header.action.icon, 0, 0, 0)
|
||||
headerActionView.setText(model.header.action.label)
|
||||
headerActionView.setOnClickListener { model.header.action.action.run() }
|
||||
} else {
|
||||
headerActionView.visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for expandable content rows.
|
||||
*/
|
||||
private class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
|
||||
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
|
||||
return expand.contactSearchKey == newItem.expand.contactSearchKey
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
|
||||
return areItemsTheSame(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for expandable content rows.
|
||||
*/
|
||||
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
|
||||
override fun bind(model: ExpandModel) {
|
||||
itemView.setOnClickListener { expandListener.invoke(model.expand) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact
|
||||
|
||||
/**
|
||||
* Represents a row in a list of Contact results.
|
||||
*/
|
||||
sealed class ContactSearchKey {
|
||||
|
||||
/**
|
||||
* Generates a ShareContact object used to display which contacts have been selected. This should *not*
|
||||
* be used for the final sharing process, as it is not always truthful about, for example, KnownRecipient of
|
||||
* a group vs. a group's Story.
|
||||
*/
|
||||
open fun requireShareContact(): ShareContact = error("This key cannot be converted into a ShareContact")
|
||||
|
||||
open fun requireParcelable(): Parcelable = error("This key cannot be parcelized")
|
||||
|
||||
/**
|
||||
* Key to a Story
|
||||
*/
|
||||
data class Story(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableContactSearchKey(ParcelableType.STORY, recipientId)
|
||||
}
|
||||
|
||||
override val isStory: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Key to a recipient which already exists in our database
|
||||
*/
|
||||
data class KnownRecipient(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableContactSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
|
||||
}
|
||||
|
||||
override val isStory: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Key to a header for a given section
|
||||
*/
|
||||
data class Header(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
|
||||
|
||||
/**
|
||||
* Key to an expand button for a given section
|
||||
*/
|
||||
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableContactSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
||||
fun asContactSearchKey(): ContactSearchKey {
|
||||
return when (type) {
|
||||
ParcelableType.STORY -> Story(recipientId)
|
||||
ParcelableType.KNOWN_RECIPIENT -> KnownRecipient(recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ParcelableType {
|
||||
STORY,
|
||||
KNOWN_RECIPIENT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
|
||||
class ContactSearchMediator(
|
||||
fragment: Fragment,
|
||||
recyclerView: RecyclerView,
|
||||
selectionLimits: SelectionLimits,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
|
||||
) {
|
||||
|
||||
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java)
|
||||
|
||||
init {
|
||||
val adapter = PagingMappingAdapter<ContactSearchKey>()
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
ContactSearchItems.register(
|
||||
mappingAdapter = adapter,
|
||||
recipientListener = this::toggleSelection,
|
||||
storyListener = this::toggleSelection,
|
||||
expandListener = { viewModel.expandSection(it.sectionKey) }
|
||||
)
|
||||
|
||||
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
|
||||
viewModel.data,
|
||||
viewModel.selectionState,
|
||||
::Pair
|
||||
)
|
||||
|
||||
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
|
||||
adapter.submitList(ContactSearchItems.toMappingModelList(data, selection))
|
||||
}
|
||||
|
||||
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
|
||||
adapter.setPagingController(controller)
|
||||
}
|
||||
|
||||
viewModel.configurationState.observe(fragment.viewLifecycleOwner) {
|
||||
viewModel.setConfiguration(mapStateToConfiguration(it))
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilterChanged(filter: String?) {
|
||||
viewModel.setQuery(filter)
|
||||
}
|
||||
|
||||
fun setKeysSelected(keys: Set<ContactSearchKey>) {
|
||||
viewModel.setKeysSelected(keys)
|
||||
}
|
||||
|
||||
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
|
||||
viewModel.setKeysNotSelected(keys)
|
||||
}
|
||||
|
||||
fun getSelectedContacts(): Set<ContactSearchKey> {
|
||||
return viewModel.getSelectedContacts()
|
||||
}
|
||||
|
||||
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
|
||||
return viewModel.selectionState
|
||||
}
|
||||
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
|
||||
viewModel.addToVisibleGroupStories(groupStories)
|
||||
}
|
||||
|
||||
private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) {
|
||||
if (isSelected) {
|
||||
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
|
||||
} else {
|
||||
viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages the querying of contact information based off a configuration.
|
||||
*/
|
||||
class ContactSearchPagedDataSource(
|
||||
private val contactConfiguration: ContactSearchConfiguration,
|
||||
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication())
|
||||
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
|
||||
|
||||
override fun size(): Int {
|
||||
return contactConfiguration.sections.sumBy {
|
||||
getSectionSize(it, contactConfiguration.query)
|
||||
}
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
|
||||
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
|
||||
val startIndex: Index = findIndex(sizeMap, start)
|
||||
val endIndex: Index = findIndex(sizeMap, start + length)
|
||||
|
||||
val indexOfStartSection = contactConfiguration.sections.indexOf(startIndex.category)
|
||||
val indexOfEndSection = contactConfiguration.sections.indexOf(endIndex.category)
|
||||
|
||||
val results: List<List<ContactSearchData>> = contactConfiguration.sections.mapIndexed { index, section ->
|
||||
if (index in indexOfStartSection..indexOfEndSection) {
|
||||
getSectionData(
|
||||
section = section,
|
||||
query = contactConfiguration.query,
|
||||
startIndex = if (index == indexOfStartSection) startIndex.offset else 0,
|
||||
endIndex = if (index == indexOfEndSection) endIndex.offset else sizeMap[section] ?: error("Unknown section")
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
return results.flatten().toMutableList()
|
||||
}
|
||||
|
||||
private fun findIndex(sizeMap: Map<ContactSearchConfiguration.Section, Int>, target: Int): Index {
|
||||
var offset = 0
|
||||
sizeMap.forEach { (key, size) ->
|
||||
if (offset + size > target) {
|
||||
return Index(key, target - offset)
|
||||
}
|
||||
|
||||
offset += size
|
||||
}
|
||||
|
||||
return Index(sizeMap.keys.last(), sizeMap.values.last())
|
||||
}
|
||||
|
||||
data class Index(val category: ContactSearchConfiguration.Section, val offset: Int)
|
||||
|
||||
override fun load(key: ContactSearchKey?): ContactSearchData? {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun getKey(data: ContactSearchData): ContactSearchKey {
|
||||
return data.contactSearchKey
|
||||
}
|
||||
|
||||
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
|
||||
val cursor: Cursor = when (section) {
|
||||
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsCursor(section, query)
|
||||
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupContacts(section, query)
|
||||
is ContactSearchConfiguration.Section.Recents -> getRecentsCursor(section, query)
|
||||
is ContactSearchConfiguration.Section.Stories -> getStoriesCursor(query)
|
||||
}!!
|
||||
|
||||
val extras: List<ContactSearchData> = when (section) {
|
||||
is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val collection = ResultsCollection(
|
||||
section = section,
|
||||
cursor = cursor,
|
||||
extraData = extras,
|
||||
cursorMapper = { error("Unsupported") }
|
||||
)
|
||||
|
||||
return collection.getSize()
|
||||
}
|
||||
|
||||
private fun getFilteredGroupStories(section: ContactSearchConfiguration.Section.Stories, query: String?): List<ContactSearchData> {
|
||||
return (contactSearchPagedDataSourceRepository.getGroupStories() + section.groupStories)
|
||||
.filter { contactSearchPagedDataSourceRepository.recipientNameContainsQuery(it.recipient, query) }
|
||||
}
|
||||
|
||||
private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return when (section) {
|
||||
is ContactSearchConfiguration.Section.Groups -> getGroupContactsData(section, query, startIndex, endIndex)
|
||||
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
|
||||
is ContactSearchConfiguration.Section.Recents -> getRecentsContactData(section, query, startIndex, endIndex)
|
||||
is ContactSearchConfiguration.Section.Stories -> getStoriesContactData(section, query, startIndex, endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNonGroupContactsCursor(section: ContactSearchConfiguration.Section.Individuals, query: String?): Cursor? {
|
||||
return when (section.transportType) {
|
||||
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)
|
||||
ContactSearchConfiguration.TransportType.SMS -> contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)
|
||||
ContactSearchConfiguration.TransportType.ALL -> contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStoriesCursor(query: String?): Cursor? {
|
||||
return contactSearchPagedDataSourceRepository.getStories(query)
|
||||
}
|
||||
|
||||
private fun getRecentsCursor(section: ContactSearchConfiguration.Section.Recents, query: String?): Cursor? {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
throw IllegalArgumentException("Searching Recents is not supported")
|
||||
}
|
||||
|
||||
return contactSearchPagedDataSourceRepository.getRecents(section)
|
||||
}
|
||||
|
||||
private fun readContactDataFromCursor(
|
||||
cursor: Cursor,
|
||||
section: ContactSearchConfiguration.Section,
|
||||
startIndex: Int,
|
||||
endIndex: Int,
|
||||
cursorRowToData: (Cursor) -> ContactSearchData,
|
||||
extraData: List<ContactSearchData> = emptyList()
|
||||
): List<ContactSearchData> {
|
||||
val results = mutableListOf<ContactSearchData>()
|
||||
|
||||
val collection = ResultsCollection(section, cursor, extraData, cursorRowToData)
|
||||
results.addAll(collection.getSublist(startIndex, endIndex))
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private fun getStoriesContactData(section: ContactSearchConfiguration.Section.Stories, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return getStoriesCursor(query)?.use { cursor ->
|
||||
readContactDataFromCursor(
|
||||
cursor = cursor,
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
cursorRowToData = {
|
||||
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromDistributionListCursor(it)
|
||||
ContactSearchData.Story(recipient, contactSearchPagedDataSourceRepository.getDistributionListMembershipCount(recipient))
|
||||
},
|
||||
extraData = getFilteredGroupStories(section, query)
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun getRecentsContactData(section: ContactSearchConfiguration.Section.Recents, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return getRecentsCursor(section, query)?.use { cursor ->
|
||||
readContactDataFromCursor(
|
||||
cursor = cursor,
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
cursorRowToData = {
|
||||
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(cursor))
|
||||
}
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return getNonGroupContactsCursor(section, query)?.use { cursor ->
|
||||
readContactDataFromCursor(
|
||||
cursor = cursor,
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
cursorRowToData = {
|
||||
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(cursor))
|
||||
}
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun getGroupContactsData(section: ContactSearchConfiguration.Section.Groups, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return contactSearchPagedDataSourceRepository.getGroupContacts(section, query)?.use { cursor ->
|
||||
readContactDataFromCursor(
|
||||
cursor = cursor,
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
cursorRowToData = {
|
||||
if (section.returnAsGroupStories) {
|
||||
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor), 0)
|
||||
} else {
|
||||
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor))
|
||||
}
|
||||
}
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* We assume that the collection is [cursor contents] + [extraData contents]
|
||||
*/
|
||||
private data class ResultsCollection(
|
||||
val section: ContactSearchConfiguration.Section,
|
||||
val cursor: Cursor,
|
||||
val extraData: List<ContactSearchData>,
|
||||
val cursorMapper: (Cursor) -> ContactSearchData
|
||||
) {
|
||||
|
||||
private val contentSize = cursor.count + extraData.count()
|
||||
|
||||
fun getSize(): Int {
|
||||
val contentsAndExpand = min(
|
||||
section.expandConfig?.let {
|
||||
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded + 1)
|
||||
} ?: Int.MAX_VALUE,
|
||||
contentSize
|
||||
)
|
||||
|
||||
return contentsAndExpand + (if (contentsAndExpand > 0 && section.includeHeader) 1 else 0)
|
||||
}
|
||||
|
||||
fun getSublist(start: Int, end: Int): List<ContactSearchData> {
|
||||
val results = mutableListOf<ContactSearchData>()
|
||||
for (i in start until end) {
|
||||
results.add(getItemAt(i))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private fun getItemAt(index: Int): ContactSearchData {
|
||||
return when {
|
||||
index == 0 && section.includeHeader -> ContactSearchData.Header(section.sectionKey, section.headerAction)
|
||||
index == getSize() - 1 && shouldDisplayExpandRow() -> ContactSearchData.Expand(section.sectionKey)
|
||||
else -> {
|
||||
val correctedIndex = if (section.includeHeader) index - 1 else index
|
||||
if (correctedIndex < cursor.count) {
|
||||
cursor.moveToPosition(correctedIndex)
|
||||
cursorMapper.invoke(cursor)
|
||||
} else {
|
||||
val extraIndex = correctedIndex - cursor.count
|
||||
extraData[extraIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldDisplayExpandRow(): Boolean {
|
||||
val expandConfig = section.expandConfig
|
||||
return when {
|
||||
expandConfig == null || expandConfig.isExpanded -> false
|
||||
else -> contentSize > expandConfig.maxCountWhenNotExpanded + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
import org.thoughtcrime.securesms.database.DistributionListDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
|
||||
/**
|
||||
* Database boundary interface which allows us to safely unit test the data source without
|
||||
* having to deal with database access.
|
||||
*/
|
||||
open class ContactSearchPagedDataSourceRepository(
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
private val contactRepository = ContactRepository(context, context.getString(R.string.note_to_self))
|
||||
|
||||
open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? {
|
||||
return contactRepository.querySignalContacts(query ?: "", includeSelf)
|
||||
}
|
||||
|
||||
open fun queryNonSignalContacts(query: String?): Cursor? {
|
||||
return contactRepository.queryNonSignalContacts(query ?: "")
|
||||
}
|
||||
|
||||
open fun queryNonGroupContacts(query: String?, includeSelf: Boolean): Cursor? {
|
||||
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
|
||||
}
|
||||
|
||||
open fun getGroupContacts(section: ContactSearchConfiguration.Section.Groups, query: String?): Cursor? {
|
||||
return SignalDatabase.groups.getGroupsFilteredByTitle(query ?: "", section.includeInactive, !section.includeV1, !section.includeMms).cursor
|
||||
}
|
||||
|
||||
open fun getRecents(section: ContactSearchConfiguration.Section.Recents): Cursor? {
|
||||
return SignalDatabase.threads.getRecentConversationList(
|
||||
section.limit,
|
||||
section.includeInactiveGroups,
|
||||
section.groupsOnly,
|
||||
!section.includeGroupsV1,
|
||||
!section.includeSms
|
||||
)
|
||||
}
|
||||
|
||||
open fun getStories(query: String?): Cursor? {
|
||||
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
|
||||
}
|
||||
|
||||
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, DistributionListDatabase.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
open fun getRecipientFromThreadCursor(cursor: Cursor): Recipient {
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
open fun getRecipientFromRecipientCursor(cursor: Cursor): Recipient {
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
|
||||
}
|
||||
|
||||
open fun getRecipientFromGroupCursor(cursor: Cursor): Recipient {
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, GroupDatabase.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
open fun getDistributionListMembershipCount(recipient: Recipient): Int {
|
||||
return SignalDatabase.distributionLists.getMemberCount(recipient.requireDistributionListId())
|
||||
}
|
||||
|
||||
open fun getGroupStories(): Set<ContactSearchData.Story> {
|
||||
return SignalDatabase.groups.groupsToDisplayAsStories.map {
|
||||
val recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromGroupId(it))
|
||||
ContactSearchData.Story(recipient, recipient.participants.size)
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
open fun recipientNameContainsQuery(recipient: Recipient, query: String?): Boolean {
|
||||
return query.isNullOrBlank() || recipient.getDisplayName(context).contains(query)
|
||||
}
|
||||
|
||||
open fun myStoryContainsQuery(query: String): Boolean {
|
||||
if (query.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val myStory = context.getString(R.string.Recipient_my_story)
|
||||
return myStory.contains(query)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class ContactSearchRepository {
|
||||
fun filterOutUnselectableContactSearchKeys(contactSearchKeys: Set<ContactSearchKey>): Single<Set<ContactSearchSelectionResult>> {
|
||||
return Single.fromCallable {
|
||||
contactSearchKeys.map {
|
||||
val isSelectable = when (it) {
|
||||
is ContactSearchKey.Expand -> false
|
||||
is ContactSearchKey.Header -> false
|
||||
is ContactSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId)
|
||||
is ContactSearchKey.Story -> canSelectRecipient(it.recipientId)
|
||||
}
|
||||
ContactSearchSelectionResult(it, isSelectable)
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canSelectRecipient(recipientId: RecipientId): Boolean {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
return if (recipient.isPushV2Group) {
|
||||
val record = SignalDatabase.groups.getGroup(recipient.requireGroupId())
|
||||
!(record.isPresent && record.get().isAnnouncementGroup && !record.get().isAdmin(Recipient.self()))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean)
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
/**
|
||||
* Simple search state for contacts.
|
||||
*/
|
||||
data class ContactSearchState(
|
||||
val query: String? = null,
|
||||
val expandedSections: Set<ContactSearchConfiguration.SectionKey> = emptySet(),
|
||||
val groupStories: Set<ContactSearchData.Story> = emptySet()
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.PagingController
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
/**
|
||||
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
|
||||
*/
|
||||
class ContactSearchViewModel(
|
||||
private val selectionLimits: SelectionLimits,
|
||||
private val contactSearchRepository: ContactSearchRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val pagingConfig = PagingConfig.Builder()
|
||||
.setBufferPages(1)
|
||||
.setPageSize(20)
|
||||
.setStartIndex(0)
|
||||
.build()
|
||||
|
||||
private val pagedData = MutableLiveData<PagedData<ContactSearchKey, ContactSearchData>>()
|
||||
private val configurationStore = Store(ContactSearchState())
|
||||
private val selectionStore = Store<Set<ContactSearchKey>>(emptySet())
|
||||
|
||||
val controller: LiveData<PagingController<ContactSearchKey>> = Transformations.map(pagedData) { it.controller }
|
||||
val data: LiveData<List<ContactSearchData>> = Transformations.switchMap(pagedData) { it.data }
|
||||
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
|
||||
val selectionState: LiveData<Set<ContactSearchKey>> = selectionStore.stateLiveData
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
|
||||
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration)
|
||||
pagedData.value = PagedData.create(pagedDataSource, pagingConfig)
|
||||
}
|
||||
|
||||
fun setQuery(query: String?) {
|
||||
configurationStore.update { it.copy(query = query) }
|
||||
}
|
||||
|
||||
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
|
||||
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
|
||||
}
|
||||
|
||||
fun setKeysSelected(contactSearchKeys: Set<ContactSearchKey>) {
|
||||
disposables += contactSearchRepository.filterOutUnselectableContactSearchKeys(contactSearchKeys).subscribe { results ->
|
||||
if (results.any { !it.isSelectable }) {
|
||||
// TODO [alex] -- Pop an error.
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
val newSelectionEntries = results.filter { it.isSelectable }.map { it.key } - getSelectedContacts()
|
||||
val newSelectionSize = newSelectionEntries.size + getSelectedContacts().size
|
||||
|
||||
if (selectionLimits.hasRecommendedLimit() && getSelectedContacts().size < selectionLimits.recommendedLimit && newSelectionSize >= selectionLimits.recommendedLimit) {
|
||||
// Pop a warning
|
||||
} else if (selectionLimits.hasHardLimit() && newSelectionSize > selectionLimits.hardLimit) {
|
||||
// Pop an error
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
selectionStore.update { state -> state + newSelectionEntries }
|
||||
}
|
||||
}
|
||||
|
||||
fun setKeysNotSelected(contactSearchKeys: Set<ContactSearchKey>) {
|
||||
selectionStore.update { it - contactSearchKeys }
|
||||
}
|
||||
|
||||
fun getSelectedContacts(): Set<ContactSearchKey> {
|
||||
return selectionStore.state
|
||||
}
|
||||
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
|
||||
configurationStore.update { state ->
|
||||
state.copy(
|
||||
groupStories = state.groupStories + groupStories.map {
|
||||
val recipient = Recipient.resolved(it.recipientId)
|
||||
ContactSearchData.Story(recipient, recipient.participants.size)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* A Contact Search Key that is backed by a recipient, along with information about whether it is a story.
|
||||
*/
|
||||
interface RecipientSearchKey {
|
||||
val recipientId: RecipientId
|
||||
val isStory: Boolean
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.contacts.selection
|
||||
|
||||
import android.os.Bundle
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
data class ContactSelectionArguments(
|
||||
val displayMode: Int = ContactsCursorLoader.DisplayMode.FLAG_ALL,
|
||||
val isRefreshable: Boolean = true,
|
||||
val displayRecents: Boolean = false,
|
||||
val selectionLimits: SelectionLimits? = null,
|
||||
val currentSelection: List<RecipientId> = emptyList(),
|
||||
val displaySelectionCount: Boolean = true,
|
||||
val canSelectSelf: Boolean = selectionLimits == null,
|
||||
val displayChips: Boolean = true,
|
||||
val recyclerPadBottom: Int = -1,
|
||||
val recyclerChildClipping: Boolean = true
|
||||
) {
|
||||
|
||||
fun toArgumentBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
putInt(DISPLAY_MODE, displayMode)
|
||||
putBoolean(REFRESHABLE, isRefreshable)
|
||||
putBoolean(RECENTS, displayRecents)
|
||||
putParcelable(SELECTION_LIMITS, selectionLimits)
|
||||
putBoolean(HIDE_COUNT, !displaySelectionCount)
|
||||
putBoolean(CAN_SELECT_SELF, canSelectSelf)
|
||||
putBoolean(DISPLAY_CHIPS, displayChips)
|
||||
putInt(RV_PADDING_BOTTOM, recyclerPadBottom)
|
||||
putBoolean(RV_CLIP, recyclerChildClipping)
|
||||
putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DISPLAY_MODE = "display_mode"
|
||||
const val REFRESHABLE = "refreshable"
|
||||
const val RECENTS = "recents"
|
||||
const val SELECTION_LIMITS = "selection_limits"
|
||||
const val CURRENT_SELECTION = "current_selection"
|
||||
const val HIDE_COUNT = "hide_count"
|
||||
const val CAN_SELECT_SELF = "can_select_self"
|
||||
const val DISPLAY_CHIPS = "display_chips"
|
||||
const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom"
|
||||
const val RV_CLIP = "recycler_view_clipping"
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
|
||||
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.ui.error.EnableCallNotificationSettingsDialog;
|
||||
@@ -185,7 +186,7 @@ import java.util.concurrent.ExecutionException;
|
||||
import kotlin.Unit;
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
|
||||
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback {
|
||||
private static final String TAG = Log.tag(ConversationFragment.class);
|
||||
|
||||
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
|
||||
@@ -223,7 +224,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
private Animation scrollButtonOutAnimation;
|
||||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
private int lastSeenScrollOffset;
|
||||
private View toolbarShadow;
|
||||
private Stopwatch startupStopwatch;
|
||||
@@ -1013,7 +1013,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
MultiselectForwardFragmentArgs.create(requireContext(),
|
||||
multiselectParts,
|
||||
args -> MultiselectForwardFragment.show(getChildFragmentManager(), args));
|
||||
args -> MultiselectForwardFragment.showBottomSheet(getChildFragmentManager(), args));
|
||||
}
|
||||
|
||||
private void handleResendMessage(final MessageRecord message) {
|
||||
@@ -1055,7 +1055,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
private void performSave(final MediaMmsMessageRecord message) {
|
||||
List<SaveAttachmentTask.Attachment> attachments = Stream.of(message.getSlideDeck().getSlides())
|
||||
.filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()))
|
||||
.map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull()))
|
||||
.map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateSent(), s.getFileName().orNull()))
|
||||
.toList();
|
||||
|
||||
if (!Util.isEmpty(attachments)) {
|
||||
@@ -1189,17 +1189,13 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
if (Math.abs(layoutManager.findFirstVisibleItemPosition() - p) < SCROLL_ANIMATION_THRESHOLD) {
|
||||
View child = layoutManager.findViewByPosition(position);
|
||||
|
||||
if (child != null && layoutManager.isViewPartiallyVisible(child, true, false)) {
|
||||
getListAdapter().pulseAtPosition(position);
|
||||
} else {
|
||||
pulsePosition = position;
|
||||
if (child == null || !layoutManager.isViewPartiallyVisible(child, true, false)) {
|
||||
layoutManager.scrollToPositionWithOffset(p, list.getHeight() / 4);
|
||||
}
|
||||
|
||||
layoutManager.scrollToPositionWithOffset(p, list.getHeight() / 4);
|
||||
} else {
|
||||
layoutManager.scrollToPositionWithOffset(p, list.getHeight() / 4);
|
||||
getListAdapter().pulseAtPosition(position);
|
||||
}
|
||||
getListAdapter().pulseAtPosition(position);
|
||||
})
|
||||
))
|
||||
.withOnInvalidPosition(() -> {
|
||||
@@ -1307,6 +1303,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismissForwardSheet() {
|
||||
}
|
||||
|
||||
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
|
||||
boolean isKeyboardOpen();
|
||||
@@ -1377,11 +1376,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
conversationDateHeader.show();
|
||||
} else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
conversationDateHeader.hide();
|
||||
|
||||
if (pulsePosition != -1) {
|
||||
getListAdapter().pulseAtPosition(pulsePosition);
|
||||
pulsePosition = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -808,6 +808,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private void startPulseOutlinerAnimation() {
|
||||
pulseOutlinerAlphaAnimator = ValueAnimator.ofInt(0, 0x66, 0).setDuration(600);
|
||||
pulseOutlinerAlphaAnimator.setRepeatCount(1);
|
||||
pulseOutlinerAlphaAnimator.addUpdateListener(animator -> {
|
||||
pulseOutliner.setAlpha((Integer) animator.getAnimatedValue());
|
||||
bodyBubble.invalidate();
|
||||
@@ -1399,6 +1400,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
throw new AssertionError();
|
||||
}
|
||||
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
|
||||
|
||||
if (((MediaMmsMessageRecord) current).getParentStoryId() != null) {
|
||||
quoteView.setMessageType(QuoteView.MessageType.STORY_REPLY);
|
||||
} else {
|
||||
quoteView.setMessageType(current.isOutgoing() ? QuoteView.MessageType.OUTGOING : QuoteView.MessageType.INCOMING);
|
||||
}
|
||||
|
||||
//noinspection ConstantConditions
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment(), chatColors);
|
||||
quoteView.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -52,7 +52,7 @@ public class ConversationMessage {
|
||||
this.body = null;
|
||||
}
|
||||
|
||||
if (!this.mentions.isEmpty() && this.body != null) {
|
||||
if (!this.mentions.isEmpty() && this.body != null && this.messageRecord.getRecipient().isGroup()) {
|
||||
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user