Compare commits

..

132 Commits

Author SHA1 Message Date
Alex Hart
1c79840684 Bump version to 5.33.3 2022-03-11 16:10:06 -04:00
Alex Hart
4ba7de9519 Updated language translations. 2022-03-11 16:09:14 -04:00
Cody Henthorne
2eb8df347e Fix mention rendering regression. 2022-03-11 14:18:42 -05:00
Rashad Sookram
9056371c41 Fix quote preview being cut off.
When determining the height to force for the animation, the text was
being measured assuming it had infinite width, which made
it seem like it could fit on one line.
2022-03-11 11:38:56 -05:00
Greyson Parrelli
1f57e1f366 Add more logging around network changes. 2022-03-11 10:35:40 -05:00
Alex Hart
aeb568bcf4 Fix crash when recreating conversation react with any emoji fragment. 2022-03-11 09:44:47 -04:00
Cody Henthorne
b7afe4411e Fix NPE in telecom integration. 2022-03-10 16:17:13 -05:00
Alex Hart
cba784b8ec Bump version to 5.33.2 2022-03-10 16:53:01 -04:00
Alex Hart
3aba15e88d Updated language translations. 2022-03-10 16:52:01 -04:00
Cody Henthorne
fa384e93dc Fix crash importing backups. 2022-03-10 15:46:45 -05:00
Alex Hart
1f3e04da29 Fix text size in generated text drawables. 2022-03-10 12:32:42 -04:00
Greyson Parrelli
a484d48377 Update network connectivity observer to be more optimistic. 2022-03-10 11:21:21 -05:00
Greyson Parrelli
15f51ea26e Fix crash if synced pinned contact is malformed. 2022-03-10 11:14:55 -05:00
Greyson Parrelli
80bfa103ab Fix narrow race around generation of some ACI keys. 2022-03-10 11:11:14 -05:00
Cody Henthorne
66f93e0d32 Do not drop 1:1 messages with mentions due to iOS and desktop regression.
iOS and Desktop both regressed in multi-forwarding by including mentions
in 1:1 forwards instead of replacing them with plain text. Android by
default drops these as invalid messages. Since there are clients in the
wild that do this now, we have to stop dropping them and try to resolve
them per normal mechanisms.
2022-03-09 12:09:46 -05:00
Rashad Sookram
366780f6cb Revert "Avoid querying conversation size twice."
This reverts commit fe088c39c7.
2022-03-09 11:55:52 -05:00
Alex Hart
fb4c1fc268 Bump version to 5.33.1 2022-03-08 16:11:46 -04:00
Alex Hart
e3bb7ccbd3 Updated language translations. 2022-03-08 16:11:09 -04:00
Alex Hart
e3fb8a2137 Only update tab if it has actually changed. 2022-03-08 12:01:21 -04:00
Alex Hart
ba4c0386ef Bump version to 5.33.0 2022-03-08 10:41:37 -04:00
Alex Hart
644945825b Updated language translations. 2022-03-08 10:41:37 -04:00
Alex Hart
7590c6dcbb Add warnings to FFs. 2022-03-08 10:41:37 -04:00
Cody Henthorne
b25cef86ee Fix different dates being used when saving attachments. 2022-03-08 10:41:37 -04:00
Jim Gustafson
fdaaa560e7 Update to RingRTC v2.19.2 2022-03-08 10:41:37 -04:00
Alex Hart
4cd438b2db Fix avatar view clickability. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
c0e1507ef4 Don't cancel KeyCachingService if not necessary.
This relates to #12043. There's some xiaomi-specific issue, and this
code was causing a pending intent creation on every app startup,
preventing it from opening. This call shouldn't be necessary unless
screenlock is active.
2022-03-08 10:41:37 -04:00
Alex Hart
8a75d78ce7 Restrict text story post sends to stories only. 2022-03-08 10:41:37 -04:00
Jordy
8176d25b4c Changed copyright to 2022.
Closes #11897
2022-03-08 10:41:37 -04:00
Greyson Parrelli
21273bc165 Fix syncing 'prefer system photos' setting. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
213517f875 Reduce sensitivity of swipe-to-archive. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
b1c006657a Fix read receipt timestamp log. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
852dcd9711 Show megaphone to improve network reliability. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
427e73f7fd Improve payment withdrawals. 2022-03-08 10:41:37 -04:00
Alex Hart
eae6a971e6 Update volume output stream when audioAttributes change. 2022-03-08 10:41:37 -04:00
Alex Hart
4b23e60dd6 Fix gallery media toast when selected item is too large.
Fixes #12011
2022-03-08 10:41:37 -04:00
Alex Hart
f0988f37f3 Update UI for View More in contact lists. 2022-03-08 10:41:37 -04:00
Alex Hart
e2e3617be9 Ensure groups stories are sent to are retained in the UI. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
3ac63cc59d Implement new feature flag strategy for AEC selection. 2022-03-08 10:41:37 -04:00
Jim Gustafson
d935d1deca Update to RingRTC v2.19.1 2022-03-08 10:41:37 -04:00
Alex Hart
3b1b00027b Fix conversation list tab bar icon colors. 2022-03-08 10:41:37 -04:00
Alex Hart
a1bc1aaa98 Only show stories and send stories with respect to capability. 2022-03-08 10:41:37 -04:00
Rashad Sookram
0ccaad1462 Update quote UI for story replies in chat. 2022-03-08 10:41:37 -04:00
Chris Eager
ad57e62680 Add staging registration constant to build config. 2022-03-08 10:41:37 -04:00
Alex Hart
4e57432dbb Improve smoothness of segmented progress bar and respect video duration. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
63412b0153 Remove leftover Valentine's Day assets. 2022-03-08 10:41:37 -04:00
Cody Henthorne
35199abf1f Fix rejoining group on linked device not showing as joined. 2022-03-08 10:41:37 -04:00
Rashad Sookram
41b5813984 Open story viewer from MyStoriesFragment. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
83215bb98f Additional work on not sending to blocked recipients. 2022-03-08 10:41:37 -04:00
clauz9
eb12395b8e Do not send to blocked recipients. 2022-03-08 10:41:37 -04:00
Cody Henthorne
4b07da4978 Inline change number flag. 2022-03-08 10:41:37 -04:00
Cody Henthorne
3fbc5423e5 Use jumbo emoji in reaction pickers. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
9d9e6e2972 Ensure inner html is escaped when bolding.
Fixes #12033
2022-03-08 10:41:37 -04:00
Greyson Parrelli
56a8451d07 Add fallback static DNS resolver. 2022-03-08 10:41:37 -04:00
Alex Hart
2483a92975 Implement story error slates.
Co-authored-by: Rashad Sookram <rashad@signal.org>
2022-03-08 10:41:37 -04:00
Alex Hart
34bbb98c96 Do not allow forwarding of unsupported content to stories. 2022-03-08 10:41:37 -04:00
Alex Hart
155bdf6164 Fix storyType selection issue in forwarder. 2022-03-08 10:41:37 -04:00
Rashad Sookram
5358ed6eff Open camera after granting permission. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
4f3bb39e5c Double-pulse message highlights. 2022-03-08 10:41:37 -04:00
clauz9
8a49534e2b Ensure bubble is highlighted after jumping.
Fixes #12017
2022-03-08 10:41:37 -04:00
Greyson Parrelli
2c3228d6df Fix issue where send button is invisible in voice note draft.
Fixes #12029
2022-03-08 10:41:37 -04:00
pauliancu97
c82d518d4d Make date view in voice note footer slightly wider.
Fixes #11728
2022-03-08 10:41:37 -04:00
Alex Hart
35cd36e9fe Implement support for 'allows replies' toggle. 2022-03-08 10:41:37 -04:00
Alex Hart
ee176cbe3d Never send a link preview via MMS. 2022-03-08 10:41:37 -04:00
Rashad Sookram
bd915cdd7f Fix crash from using a closed Cursor.
The call to setActive was causing the cursor held by the ViewModel to be
used, which hadn't been updated yet.
2022-03-08 10:41:37 -04:00
Rashad Sookram
c27f5787fe Fix reaction overlay shade with gesture nav. 2022-03-08 10:41:37 -04:00
Alex Hart
f6cdf459bb Update Views repo to pull view receipts instead of read receipts. 2022-03-08 10:41:37 -04:00
Alex Hart
4e851f90df Hide empty text until after we've tried to load stories. 2022-03-08 10:41:37 -04:00
Cody Henthorne
8d8a2a8eef Fix navigation crash in welcome fragment. 2022-03-08 10:41:37 -04:00
Cody Henthorne
277c17de83 Fix reactions vibrating in release notes channel. 2022-03-08 10:41:37 -04:00
Alex Hart
d5fd424b95 Fix several over-the-wire story issues.
Co-authored-by: Rashad Sookram <rashad@signal.org>
2022-03-08 10:41:37 -04:00
Cody Henthorne
e701e4bff0 Don't allow rate limit responses to end all group sends. 2022-03-08 10:41:37 -04:00
Alex Hart
0ddfb4456b Implement better stability while scrolling between pages. 2022-03-08 10:41:37 -04:00
Cody Henthorne
69dc31681d Apply server returned group patch instead of local only. 2022-03-08 10:41:37 -04:00
Alex Hart
2d7655a6bb Implement story ring support. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
fe088c39c7 Avoid querying conversation size twice. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
731714d263 Remove unnecessary entry from spinner manifest. 2022-03-08 10:41:37 -04:00
Greyson Parrelli
c165636180 Make perf builds profileable. 2022-03-08 10:41:37 -04:00
Jon Chambers
372dd13eba Accept both HTTP/413 and HTTP/429 as rate-limit responses. 2022-03-08 10:41:37 -04:00
Alex Hart
b35ef0bb4d Send viewed receipts for stories. 2022-03-08 10:41:37 -04:00
Alex Hart
bd58c91d2c Refactor viewer to prepare for enhanced video duration support. 2022-03-08 10:41:36 -04:00
Cody Henthorne
9a5fcdbe4d Fix emoji search showing in recent emoji bug. 2022-03-08 10:41:36 -04:00
Alex Hart
2452056cbe Fix issue where Story preview was not clickable. 2022-03-08 10:41:36 -04:00
Alex Hart
bdf7e5d367 Prevent displaying my stories page when none are present in viewer. 2022-03-08 10:41:36 -04:00
Alex Hart
aae683af41 Fix ConnectivityManager leak in MediaSelectionActivity. 2022-03-08 10:41:36 -04:00
Alex Hart
174cd860a0 Implement Stories feature behind flag.
Co-Authored-By: Greyson Parrelli <37311915+greyson-signal@users.noreply.github.com>
Co-Authored-By: Rashad Sookram <95182499+rashad-signal@users.noreply.github.com>
2022-03-08 10:41:36 -04:00
Alex Hart
765185952e Do not hook up check changed listener until after view state is restored. 2022-03-08 10:41:36 -04:00
Greyson Parrelli
f4002850bb Add a ColumnTransformer system to Spinner. 2022-03-08 10:41:36 -04:00
Greyson Parrelli
935dd7de45 Remove E164s most places and prefer ServiceId more places.\ 2022-03-08 10:41:36 -04:00
Cody Henthorne
d6b6884c69 Integrate calling with Android Telecom system. 2022-03-08 10:41:36 -04:00
Alex Hart
2ed39e4448 Add subscription cancellation step during account deletion. 2022-03-01 10:47:24 -05:00
Cody Henthorne
2de5ea43fb Add message type description to spinner as meta_type. 2022-03-01 10:47:23 -05:00
gram-signal
88d2d4d9c7 Switch from binary to streaming protos when using CDSHv1.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2022-03-01 10:47:23 -05:00
Cody Henthorne
aff0c43b39 Prevent internal shake to report dialog from showing after locking. 2022-03-01 10:47:23 -05:00
Cody Henthorne
bd18b731c8 Add metrics logging around get resumable upload url request. 2022-03-01 10:47:23 -05:00
Alex Hart
7b499f96be Implement donation receipts. 2022-03-01 10:47:23 -05:00
Alex Hart
63dab3f4b0 Add support for specific toasts when backup restoration cannot proceed.
Fixes #10918
2022-03-01 10:47:23 -05:00
Greyson Parrelli
80598814bd Remove unused PushServiceSocket method. 2022-03-01 10:47:23 -05:00
clauz9
b00abf1667 Fix internal-only crash when submitting a debuglog during registration. 2022-03-01 10:47:23 -05:00
Greyson Parrelli
9594be8fcf Add a 'Recent' tab to Spinner. 2022-03-01 10:47:23 -05:00
Greyson Parrelli
acecd5f013 Update Spinner font styles. 2022-03-01 10:47:23 -05:00
Greyson Parrelli
2d1efb604c Add paging to Spinner browser. 2022-03-01 10:47:23 -05:00
Greyson Parrelli
a84c971cbe Bump version to 5.32.15 2022-03-01 09:17:58 -05:00
Greyson Parrelli
7564ef4811 Updated language translations. 2022-03-01 09:17:23 -05:00
Greyson Parrelli
01e75120a7 Improve network reliability. 2022-03-01 09:17:22 -05:00
Cody Henthorne
1314b04994 Bump version to 5.32.14 2022-02-24 13:06:36 -05:00
Cody Henthorne
253cc5fec4 Updated language translations. 2022-02-24 12:55:47 -05:00
Greyson Parrelli
c296a28a4a Update client-side max envelope size to 256KB to match server. 2022-02-24 12:52:06 -05:00
Greyson Parrelli
ff95319559 Bump version to 5.32.13 2022-02-23 12:19:58 -05:00
Greyson Parrelli
3aa770ee08 Updated language translations. 2022-02-23 12:19:35 -05:00
Greyson Parrelli
653410cf27 Only generate a PNI key if necessary. 2022-02-23 12:15:18 -05:00
Greyson Parrelli
ba08dbef5f Fix crash on conversation settings screen for longtime-unregistered users. 2022-02-23 12:05:44 -05:00
Cody Henthorne
c1df628079 Bump version to 5.32.12 2022-02-22 12:25:02 -05:00
Cody Henthorne
e72cac7db5 Updated language translations. 2022-02-22 12:14:50 -05:00
Greyson Parrelli
cbfa573d3d Improve logging around profile uploads. 2022-02-22 11:37:29 -05:00
Greyson Parrelli
1b404cef34 Fix crash if you've been unregistered for couple months. 2022-02-22 11:36:23 -05:00
Greyson Parrelli
cb66996407 Bump version to 5.32.11 2022-02-21 09:33:36 -05:00
Greyson Parrelli
96f908b068 Updated language translations. 2022-02-21 09:33:15 -05:00
Greyson Parrelli
472c8a441f Allow late initialization of some PNI keys. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
1f0c56546e Improve robustness of PNI migration job. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
97f8b5988d Refactor LiveRecipient fetch to be more clear. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
19dc90b68b Allow group leave operations on blocked groups.
We should be leaving groups *before* they're blocked, but this helps
some other cases.
2022-02-20 22:56:23 -05:00
Greyson Parrelli
67f0ba8624 Bump version to 5.32.10 2022-02-18 23:28:17 -05:00
Greyson Parrelli
a23c27b54b Updated language translations. 2022-02-18 23:27:59 -05:00
Greyson Parrelli
34dec1aec2 Fix reaction bar positioning for scaled items. 2022-02-18 23:27:51 -05:00
Greyson Parrelli
4f1aa34a46 Address issues with PNI app migration. 2022-02-18 23:03:24 -05:00
Greyson Parrelli
a207bf965a Bump version to 5.32.9 2022-02-18 17:35:00 -05:00
Greyson Parrelli
33457acee2 Updated language translations. 2022-02-18 17:34:45 -05:00
Greyson Parrelli
80622147ab Migrate importance of Background channel from Other channel. 2022-02-18 16:00:11 -05:00
Greyson Parrelli
719f5e28d0 fixup! Do not run prekey jobs if you're not registered. 2022-02-18 15:54:15 -05:00
Greyson Parrelli
c2830163b8 Do not run prekey jobs if you're not registered. 2022-02-18 15:23:06 -05:00
Greyson Parrelli
bec9b3d88c Update reaction bar positioning to sit above short messages. 2022-02-18 12:13:57 -05:00
Rashad Sookram
8e25719b7b Fix layout loop while ellipsizing. 2022-02-18 12:12:45 -05:00
738 changed files with 29605 additions and 3277 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.components.segmentedprogressbar
data class SegmentState(
val position: Long,
val duration: Long
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -195,6 +195,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
colorize("SenderKey", recipient.senderKeyCapability),
", ",
colorize("ChangeNumber", recipient.changeNumberCapability),
", ",
colorize("Stories", recipient.storiesCapability),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
package org.thoughtcrime.securesms.contacts.paged
data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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