Compare commits

..

168 Commits

Author SHA1 Message Date
Greyson Parrelli
a9ae6d5d9b Bump version to 5.40.4.1 2022-06-20 12:07:44 -04:00
Greyson Parrelli
7b5ebea9c3 Remove inactive KBS fallback. 2022-06-20 12:07:44 -04:00
Greyson Parrelli
556e480b06 Bump version to 5.40.4 2022-06-02 11:50:22 -04:00
Greyson Parrelli
d08bee3413 Updated language translations. 2022-06-02 11:49:57 -04:00
Cody Henthorne
e83cb6fa8b Fix QR scanning bug when using camerax. 2022-06-02 11:42:25 -04:00
Greyson Parrelli
499cdd9f29 Make Github action build a specific variant. 2022-06-02 08:50:21 -04:00
Greyson Parrelli
13aa150206 Bump version to 5.40.3 2022-06-02 00:32:17 -04:00
Greyson Parrelli
63d6bab7d6 Updated language translations. 2022-06-02 00:31:39 -04:00
Cody Henthorne
d6108fbbf3 Add force legacy QR scanning switch. 2022-06-01 16:38:15 -04:00
Cody Henthorne
4c44f1ee02 Tweak new QR processing some more. 2022-06-01 14:39:42 -04:00
Greyson Parrelli
f4c728f57c Bump version to 5.40.2 2022-05-31 10:16:30 -04:00
Greyson Parrelli
58cebf7346 Updated language translations. 2022-05-31 10:15:40 -04:00
Cody Henthorne
2446792c62 Tweak QR code capture configuration. 2022-05-30 15:37:01 -04:00
Cody Henthorne
259a86b605 Fix lost scroll position in conversation list bug. 2022-05-30 14:33:04 -04:00
Jim Gustafson
fafe795f39 Update to RingRTC v2.20.7 2022-05-27 13:50:00 -07:00
Alex Hart
9a9636b58f Bump version to 5.40.1 2022-05-27 16:57:05 -03:00
Alex Hart
d48a686d98 Updated language translations. 2022-05-27 16:56:13 -03:00
Cody Henthorne
d69d1c8967 Fix IAE crash in link device transition. 2022-05-27 09:51:20 -04:00
Alex Hart
60b6a9ff3f Prevent crash when ConversationListFragment list is nullified. 2022-05-27 09:55:12 -03:00
Alex Hart
f85803c1fe Bump version to 5.40.0 2022-05-26 14:24:57 -03:00
Alex Hart
9dea815fce Updated language translations. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4e01336b2f Fix unclosed streams during backup export. 2022-05-26 14:24:57 -03:00
Cody Henthorne
9fbc7c0f65 Fix stories viewed not updating in UI. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4d028d1867 Improve messaging around story send failures. 2022-05-26 14:24:57 -03:00
Cody Henthorne
95a46f1ce5 Show user a toast when an unexpected send text story fails. 2022-05-26 14:24:57 -03:00
Cody Henthorne
26a84c5546 Show calling service notification immediately. 2022-05-26 14:24:57 -03:00
Cody Henthorne
652d0d46ed Add foreground service type to WebRtcCallService. 2022-05-26 14:24:57 -03:00
Cody Henthorne
e0f3e34899 Attempt to get service name in start foreground exception stack trace. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4a8083f7b1 Fix Vivo NPE quirk. 2022-05-26 14:24:57 -03:00
Cody Henthorne
08556b111b Fix ISE crash. 2022-05-26 14:24:57 -03:00
Cody Henthorne
7e7bc13b62 Swallow too many pending intents exception. 2022-05-26 14:24:56 -03:00
Cody Henthorne
5115eb125d Fix conversation search not showing after entering via settings. 2022-05-26 14:24:56 -03:00
Ahmad Alturki
3ec55b24f8 Fix swapped group title & avatar on rtl layout.
Fixes #12612
2022-05-26 14:24:56 -03:00
Cody Henthorne
5dba1067d6 Fix conversation list memory leak. 2022-05-26 14:24:56 -03:00
Cody Henthorne
2a91c67c51 Add sending and error states for story group replies. 2022-05-26 14:24:56 -03:00
Alex Hart
a29bc1da8c Add proper hyphenation break to badge name. 2022-05-26 14:24:56 -03:00
Alex Hart
32b4d11a82 Fix crash in onPlaying if fragment is detached. 2022-05-26 14:24:56 -03:00
Jim Gustafson
f013f7357f Update to RingRTC v2.20.6 2022-05-26 14:24:56 -03:00
Alex Hart
e37150e98a Update capabilities logging. 2022-05-26 14:24:56 -03:00
Alex Hart
eaa7262b2f Add debug log entry for video player pool usage. 2022-05-26 14:24:56 -03:00
Alex Hart
63f4f0bcec Fix note to self label in conversation settings. 2022-05-26 14:24:56 -03:00
Alex Hart
6dec6cef27 Add decline code messages into expiration sheet. 2022-05-24 15:03:54 -03:00
Greyson Parrelli
4d8faffb75 Notify recipient changes after bulk registration update. 2022-05-24 15:03:54 -03:00
Alex Hart
fa6bb07e8a Update strings for unclear translations. 2022-05-24 15:03:54 -03:00
Cody Henthorne
d260c48393 Fix device linking issues on newer devices. 2022-05-24 15:03:54 -03:00
Cody Henthorne
cc31417c97 Fix desugar crash on spinner builds. 2022-05-24 15:03:54 -03:00
Alex Hart
4d2af5b536 Rotate gift badges flag. 2022-05-24 15:03:54 -03:00
Alex Hart
6029c8ae4a Bump version to 5.38.3 2022-05-24 14:31:18 -03:00
Alex Hart
3bc18c3300 Updated language translations. 2022-05-24 14:30:15 -03:00
Cody Henthorne
a652bc65cc Fix font version check timeout. 2022-05-23 22:06:18 -04:00
Cody Henthorne
53252aa797 Bump version to 5.39.2 2022-05-19 16:36:06 -04:00
Cody Henthorne
956c1d96af Updated language translations. 2022-05-19 16:31:46 -04:00
Greyson Parrelli
d0ecbda962 Hide attachment keyboard if system keyboard shows. 2022-05-19 08:53:12 -04:00
Greyson Parrelli
5d880e2b2a Fix bug where searching emoji would dismiss the view. 2022-05-19 08:50:40 -04:00
Greyson Parrelli
bb13be1e7a Bump version to 5.39.1 2022-05-18 17:57:56 -04:00
Greyson Parrelli
05975a0068 Fix scrolling to last seen. 2022-05-18 17:51:00 -04:00
Greyson Parrelli
153feb002e Update R8 to 3.3.28 2022-05-18 17:34:04 -04:00
Cody Henthorne
f63ed8f269 Bump version to 5.39.0 2022-05-18 14:18:31 -04:00
Cody Henthorne
139a503403 Updated language translations. 2022-05-18 14:11:46 -04:00
Cody Henthorne
db4d072bd9 Upgrade kotlin to 1.6.21
Also fix a collection of warnings.
2022-05-18 14:05:17 -04:00
Cody Henthorne
42b0842aab Fix ANR when showing media in notifications. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
9ab275195f Add support for CDSI. 2022-05-18 11:54:17 -04:00
Alex Hart
8407f2ff69 Add guard against out of bounds indices in story viewer. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
9d518879dd Update libsignal-client to 0.17.0 2022-05-18 11:54:17 -04:00
Alex Hart
588663b3c2 Add better handling for unexpected cancellations. 2022-05-18 11:54:17 -04:00
Alex Hart
77f8489e51 Scroll to top on chat press when already on that tab. 2022-05-18 11:54:17 -04:00
Cody Henthorne
3c08b070fc Fetch PNI Credential during own profile refresh. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
dda5ce4809 Add basic CDSv2 database writes and unit tests. 2022-05-18 11:54:17 -04:00
Alex Hart
307be5c75e Ensure callback is registered for shaking gifts. 2022-05-18 11:54:17 -04:00
Alex Hart
a0b89051cf Add duration info to gift row item. 2022-05-18 11:54:17 -04:00
Alex Hart
a1025a8e9a Add expiry information to gift conversation items. 2022-05-18 11:54:17 -04:00
Alex Hart
ce2418ce9f Consolidate local badge writes. 2022-05-18 11:54:17 -04:00
Cody Henthorne
ec3540e200 Fix long text in Safety Number Change dialog. 2022-05-18 11:54:17 -04:00
Alex Hart
ff4311d114 Add outline around sent gift reply thumb. 2022-05-18 11:54:17 -04:00
Alex Hart
425a13e68c Mark sent gift viewed when opened. 2022-05-18 11:54:17 -04:00
Alex Hart
15af1d3bd1 Add default animations to gift flow. 2022-05-18 11:54:17 -04:00
Alex Hart
2d57cb4ed0 Enqueue profile refresh sync after badge redemption. 2022-05-18 11:54:17 -04:00
Alex Hart
25788ef751 Do not include self in recents list for gift badging. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
b3086e595f Fix abbreviations with some emoji.
Fixes #12212
2022-05-18 11:54:17 -04:00
Greyson Parrelli
57e233413a Update string for group invite. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
f5777d58fc Fix situation where two keyboards could be showing. 2022-05-18 11:54:17 -04:00
Alex Hart
6b55cd0128 Always pop open keyboard when opening group reply sheet. 2022-05-18 11:54:17 -04:00
Alex Hart
a03c49e12c Implement group story notifications. 2022-05-18 11:54:17 -04:00
Alex Hart
01543dd52b Utilize round outline for deleted messages. 2022-05-18 11:54:17 -04:00
Alex Hart
987f69227a Add polish to story replies button and direct reply sheet. 2022-05-18 11:54:17 -04:00
Alex Hart
e51841a28b Fix freeze of first story first post. 2022-05-18 11:54:17 -04:00
Alex Hart
bcfe2fef72 Hide gift badge row if user does not have capability set and rotate flag. 2022-05-18 11:54:17 -04:00
Alex Hart
9ed3f95ab8 Ignore duplicate stories in sync messages. 2022-05-18 11:54:17 -04:00
Cody Henthorne
0fe0765e63 Bump version to 5.38.5 2022-05-17 14:04:33 -04:00
Cody Henthorne
6e6752cfed Updated language translations. 2022-05-17 14:04:23 -04:00
Cody Henthorne
0107e8e6eb Reduce minimum translation requirement. 2022-05-17 13:41:30 -04:00
Cody Henthorne
7fe5376772 Bump version to 5.38.4 2022-05-17 11:14:42 -04:00
Cody Henthorne
30d2d12f89 Updated language translations. 2022-05-17 11:05:31 -04:00
Cody Henthorne
98ab48f0eb Revert "Fix system UI freeze with image notifications."
This reverts commit 8f2c5d43df.
2022-05-17 11:00:55 -04:00
Cody Henthorne
a181ed0420 Revert "Always try to close the PartProvider open file pipe."
This reverts commit d97184ef60.
2022-05-17 11:00:54 -04:00
Cody Henthorne
dbddb274db Revert "Fix NPE in PartProvider."
This reverts commit 3b16a1d28c.
2022-05-17 11:00:52 -04:00
Cody Henthorne
8502badb6d Bump version to 5.38.3 2022-05-16 12:16:39 -04:00
Cody Henthorne
cb4ba1ccfe Updated language translations. 2022-05-16 12:06:36 -04:00
Cody Henthorne
3b16a1d28c Fix NPE in PartProvider. 2022-05-16 11:32:23 -04:00
Cody Henthorne
ba1473acb9 Revert "Fix Google Camera social share."
This reverts commit c078d08df7.
2022-05-16 11:02:02 -04:00
Cody Henthorne
709c866786 Revert "Fix direct sharing, again."
This reverts commit ad626fe7ee.
2022-05-16 11:02:01 -04:00
Alex Hart
ab4e5b1d7c Bump version to 5.38.2 2022-05-13 16:30:36 -03:00
Alex Hart
7b2552e8f2 Updated language translations. 2022-05-13 16:29:28 -03:00
Cody Henthorne
a501940909 Fix Payment to Help navigation. 2022-05-13 14:27:12 -04:00
Cody Henthorne
a3bbf944e5 Handle bluetooth permission crash during calls. 2022-05-13 12:39:23 -04:00
Greyson Parrelli
97d41fdd1e Small refactor of RecipientDatabase androidTests. 2022-05-13 11:39:43 -04:00
Greyson Parrelli
a9bdc1abfc Only stop the FCM foreground service if it was used. 2022-05-13 09:12:02 -04:00
Cody Henthorne
ad626fe7ee Fix direct sharing, again. 2022-05-13 08:35:39 -04:00
Cody Henthorne
d97184ef60 Always try to close the PartProvider open file pipe. 2022-05-13 08:31:23 -04:00
Alex Hart
b527b2ffb9 Bump version to 5.38.1 2022-05-12 17:30:58 -03:00
Alex Hart
468cda034a Updated language translations. 2022-05-12 17:30:58 -03:00
Cody Henthorne
8f2c5d43df Fix system UI freeze with image notifications. 2022-05-12 17:30:58 -03:00
Cody Henthorne
9bc4dfc3f6 Fix PNI crash in in group processing. 2022-05-12 17:30:58 -03:00
Greyson Parrelli
dc095c9db4 Give recipient resolves their own thread pool. 2022-05-12 17:30:58 -03:00
Greyson Parrelli
ef85b29ddf Fix keyboard icon when opening emoji keyboard. 2022-05-12 17:30:58 -03:00
Alex Hart
392a66ed59 Fix bad toolbar animations when switching to and from archive fragment. 2022-05-12 17:30:58 -03:00
Cody Henthorne
c078d08df7 Fix Google Camera social share. 2022-05-12 11:56:55 -04:00
Alex Hart
c89b818a31 Bump version to 5.38.0 2022-05-12 10:42:21 -03:00
Alex Hart
e495c25687 Updated language translations. 2022-05-12 10:42:21 -03:00
Alex Hart
3b2a3500a1 Do not send viewed receipt to gift sender after redemption. 2022-05-12 10:42:21 -03:00
clauz9
d3d9b95924 Fix navigation for creating a new pin if forgotten or skipped during registration
Co-authored-by: henry <henry.ph2@gmail.com>

Closes #12183
2022-05-12 10:42:21 -03:00
Sgn-32
12d1254d4e Update libphonenumber to 8.12.48 2022-05-12 10:42:21 -03:00
Cody Henthorne
ecc358ef40 Consolidate S3 requests into one interface. 2022-05-12 10:42:21 -03:00
Cody Henthorne
bb963f9210 Add remote megaphone. 2022-05-12 10:42:21 -03:00
Cody Henthorne
820277800b Ignore identity updates for self. 2022-05-12 10:42:21 -03:00
Cody Henthorne
14b2d12895 Reduce disk reads on main thread. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
92a506e4da Add a donate megaphone for Q2 2022. 2022-05-12 10:42:21 -03:00
Cody Henthorne
12e6ebb4df Improve performance of GV2 profile fetch and mentions initialization. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
c0db88960c Make FcmFetchForegroundService stop itself. 2022-05-12 10:42:21 -03:00
Alex Hart
eeb4cdf064 Add strict-mode logging for disk access on Spinner variant. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
85cecbb7e9 Remove the chat colors megaphone. 2022-05-12 10:42:21 -03:00
Alex Hart
33d60ebe14 Implement proper group story reply deletion for remotely deleted group stories. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
9afeb206fc Refactor FCM processing to improve use of foreground services. 2022-05-12 10:42:21 -03:00
Cody Henthorne
06a49b5d5a Force use of system settings to configure notifications on SDK30+. 2022-05-12 10:42:21 -03:00
Alex Hart
68ba3433a3 Always display donation receipts page. 2022-05-12 10:42:21 -03:00
Alex Hart
eaf36be9f6 NotificationThread migration. 2022-05-12 10:42:21 -03:00
Alex Hart
af9465fefe Add sent story syncing. 2022-05-12 10:42:21 -03:00
Alex Hart
8ca0f4baf4 Add support for replying to gift badges. 2022-05-12 10:42:21 -03:00
Winston Cooke
0c1edd6a56 Update CONTRIBUTING.md link from master to main
Fixes #12242
2022-05-12 10:42:21 -03:00
Alex Hart
df88c2fd14 Update ViewModel file template to use RxStore. 2022-05-12 10:42:21 -03:00
Alex Hart
c698bfca44 Fix currency selection disabled state. 2022-05-12 10:42:21 -03:00
Alex Hart
431f5501c6 Do not display keyboard when entering blocked users page.
Fixes #12241
2022-05-12 10:42:21 -03:00
Alex Hart
9a20447993 Add touch delegate for user avatar in conversation list view. 2022-05-12 10:42:21 -03:00
Sgn-32
049e5a1b99 Fix animation for call buttons.
Closes #12240
2022-05-12 10:42:21 -03:00
Sgn-32
4cbacc9804 Change text when blocking/unblocking unregistered recipient.
Closes #12239
2022-05-12 10:42:20 -03:00
Sgn-32
6462d053ae Add divider in ChatSettingsFragment.
Closes #12238
2022-05-11 09:29:14 -03:00
Alex Hart
0f08acbc04 Verify recipient before launching google pay sheet in badge gifting flow. 2022-05-11 09:29:14 -03:00
Alex Hart
dc5f7d0906 Add gift tab in donation receipts page. 2022-05-11 09:29:14 -03:00
Alex Voloshyn
60b20a9b8a Use shorter fog report URI in wallet 2022-05-11 09:29:14 -03:00
Alex Hart
ec361d6349 Update gift badge open animation to use anticipate interpolator. 2022-05-11 09:29:14 -03:00
Alex Hart
1f8f1d433b Add Gift badging bow. 2022-05-11 09:29:14 -03:00
Alex Hart
bc44704f54 Center currency code in selector. 2022-05-11 09:29:14 -03:00
Alex Hart
756eafe3c8 Add slide animation to conversation list to archive. 2022-05-11 09:29:14 -03:00
Alex Hart
e770241ed4 Remove story text posts feature flag. 2022-05-11 09:29:14 -03:00
Cody Henthorne
4b8729c2ae Fix story unavailable emoji render bug. 2022-05-11 09:29:14 -03:00
Alex Hart
8261e21005 Lock story viewer orientation to portrait. 2022-05-11 09:29:14 -03:00
Alex Hart
1b1bbbab7a Add multi-device sync for viewed status of redeemed gift badge. 2022-05-11 09:29:14 -03:00
Alex Hart
964d214434 Remove inset for check circle and update copy for private and group story pickers. 2022-05-11 09:29:14 -03:00
Alex Hart
e2b0079a5c Utilize sending reply instead of reply sent in story reply toast. 2022-05-11 09:29:14 -03:00
Alex Hart
158f77a634 Add thread display body and proper image for gift badges. 2022-05-11 09:29:14 -03:00
Alex Hart
1345413645 Ensure new storage id is synchronized to recipient. 2022-05-11 09:29:14 -03:00
Alex Hart
ee69895123 Bump version to 5.37.4 2022-05-11 09:23:56 -03:00
Alex Hart
f25f47654e Updated language translations. 2022-05-11 09:23:30 -03:00
Alex Hart
8f52f803cf Ensure networking is not performed on main during Subscription creation. 2022-05-11 09:09:07 -03:00
Alex Hart
82d42c03f7 Bump version to 5.37.3 2022-05-09 15:31:04 -03:00
Alex Hart
c0f8e5adbf Updated language translations. 2022-05-09 15:30:26 -03:00
Cody Henthorne
c54c73cb48 Exclude visible thread from notification shown check. 2022-05-09 12:14:30 -04:00
Cody Henthorne
02c8656b92 Fix remove from group bug. 2022-05-09 12:13:52 -04:00
551 changed files with 16459 additions and 5315 deletions

View File

@@ -15,4 +15,4 @@ jobs:
run: cd reproducible-builds && docker build -t signal-android . && cd ..
- name: Test build
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assembleRelease
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assemblePlayProdRelease

View File

@@ -1,18 +1,18 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.util.livedata.Store
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.util.rx.RxStore
#end
#parse("File Header.java")
class ${NAME}ViewModel : ViewModel() {
private val store = Store(${NAME}State())
private val store = RxStore(${NAME}State())
private val disposables = CompositeDisposable()
val state: LiveData<${NAME}State> = store.stateLiveData
val state: Flowable<${NAME}State> = store.stateFlowable
override fun onCleared() {
disposables.clear()

View File

@@ -28,7 +28,7 @@ https://www.transifex.com/projects/p/signal-android/
## Contributing Code
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md), that might answer some of your questions.
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions.
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.

View File

@@ -63,15 +63,15 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 1047
def canonicalVersionName = "5.37.2"
def canonicalVersionCode = 1064
def canonicalVersionName = "5.40.4.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
'armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4]
def abiPostFix = ['universal' : 5,
'armeabi-v7a' : 6,
'arm64-v8a' : 7,
'x86' : 8,
'x86_64' : 9]
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
@@ -179,7 +179,7 @@ android {
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSH_URL", "\"https://cdsh.staging.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
@@ -196,17 +196,12 @@ android {
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", "\"2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"42e36b74794abe612d698308b148ff8a7dc5fdc6ad28d99bc5024ed6ece18dfe\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
"new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
"}"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXQ==\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
@@ -344,11 +339,7 @@ android {
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
"new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
"}"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
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\""
@@ -456,6 +447,7 @@ dependencies {
implementation project(':image-editor')
implementation project(':donations')
implementation project(':contacts')
implementation project(':qr')
implementation libs.libsignal.android
implementation libs.google.protobuf.javalite
@@ -541,6 +533,9 @@ dependencies {
androidTestImplementation testLibs.androidx.test.ext.junit
androidTestImplementation testLibs.espresso.core
androidTestImplementation testLibs.androidx.test.core
androidTestImplementation testLibs.androidx.test.core.ktx
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
testImplementation testLibs.espresso.core

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Android test to help show SNC dialog quickly with custom data to make sure it displays properly.
*/
@RunWith(AndroidJUnit4::class)
class SafetyNumberChangeDialogPreviewer {
@get:Rule val harness = SignalActivityRule()
@Test
fun testShowLongName() {
val other: Recipient = Recipient.resolved(harness.others.first())
SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("Super really long name like omg", "But seriously it's long like really really long"))
harness.setVerified(other, IdentityDatabase.VerifiedStatus.VERIFIED)
harness.changeIdentityKey(other)
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
scenario.onActivity {
SafetyNumberChangeDialog.show(it.supportFragmentManager, other.id)
}
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
// ThreadUtil.sleep(15000)
}
}

View File

@@ -1,37 +0,0 @@
package org.thoughtcrime.securesms.database
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import java.math.BigDecimal
import java.util.Currency
class DonationReceiptDatabaseTest {
private val records = listOf(
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(100), Currency.getInstance("USD"))),
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(200), Currency.getInstance("USD")))
)
@Test
fun givenNoReceipts_whenICheckHasReceipts_thenIExpectFalse() {
assertFalse(SignalDatabase.donationReceipts.hasReceipts())
}
@Test
fun givenOneReceipt_whenICheckHasReceipts_thenIExpectTrue() {
SignalDatabase.donationReceipts.addReceipt(records.first())
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
}
@Test
fun givenMultipleReceipts_whenICheckHasReceipts_thenIExpectTrue() {
records.forEach {
SignalDatabase.donationReceipts.addReceipt(it)
}
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
}
}

View File

@@ -0,0 +1,188 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MmsDatabaseTest_gifts {
private lateinit var mms: MmsDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
private lateinit var recipients: List<RecipientId>
@Before
fun setUp() {
mms = SignalDatabase.mms
mms.deleteAllThreads()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
}
@Test
fun givenNoSentGifts_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
val result = mms.setOutgoingGiftsRevealed(listOf(1))
assertTrue(result.isEmpty())
}
@Test
fun givenSentGift_whenISetOutgoingGiftsRevealed_thenIExpectNonEmptyListContainingThatGift() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertTrue(result.isNotEmpty())
assertEquals(messageId, result.first().messageId.id)
}
@Test
fun givenViewedSentGift_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
mms.setOutgoingGiftsRevealed(listOf(messageId))
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertTrue(result.isEmpty())
}
@Test
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForOne_thenIExpectNonEmptyListContainingThatGift() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertEquals(1, result.size)
assertEquals(messageId, result.first().messageId.id)
}
@Test
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForBoth_thenIExpectNonEmptyListContainingThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForBothGifts_thenIExpectNonEmptyListContainingJustThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForAllThree_thenIExpectNonEmptyListContainingJustThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId3 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2, messageId3))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForNonGift_thenIExpectEmptyList() {
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId3 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId3))
assertTrue(result.isEmpty())
}
}

View File

@@ -191,4 +191,51 @@ class MmsDatabaseTest_stories {
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
}
@Test
fun givenNoStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// GIVEN
MmsHelper.insert(
IncomingMediaMessage(
from = recipients[0],
sentTimeMillis = 200,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
),
-1L
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenOutgoingStoryExistsForRecipientAndTime_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectTrue() {
// GIVEN
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = -1L
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(myStory.id, 200)
// THEN
assertTrue(result)
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.database
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
@@ -20,7 +21,8 @@ object MmsHelper {
viewOnce: Boolean = false,
distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT,
threadId: Long = 1,
storyType: StoryType = StoryType.NONE
storyType: StoryType = StoryType.NONE,
giftBadge: GiftBadge? = null
): Long {
val message = OutgoingMediaMessage(
recipient,
@@ -40,7 +42,7 @@ object MmsHelper {
emptyList(),
emptySet(),
emptySet(),
null
giftBadge
)
return insert(

View File

@@ -1,6 +1,9 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
@@ -8,25 +11,55 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.core.util.ThreadUtil
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
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
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest {
class RecipientDatabaseTest_getAndPossiblyMerge {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var identityDatabase: IdentityDatabase
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
private lateinit var groupDatabase: GroupDatabase
private lateinit var threadDatabase: ThreadDatabase
private lateinit var smsDatabase: MessageDatabase
private lateinit var mmsDatabase: MessageDatabase
private lateinit var sessionDatabase: SessionDatabase
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())
@@ -34,6 +67,19 @@ class RecipientDatabaseTest {
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
recipientDatabase = SignalDatabase.recipients
identityDatabase = SignalDatabase.identities
groupReceiptDatabase = SignalDatabase.groupReceipts
groupDatabase = SignalDatabase.groups
threadDatabase = SignalDatabase.threads
smsDatabase = SignalDatabase.sms
mmsDatabase = SignalDatabase.mms
sessionDatabase = SignalDatabase.sessions
mentionDatabase = SignalDatabase.mentions
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
distributionListDatabase = SignalDatabase.distributionLists
ensureDbEmpty()
SignalStore.account().setAci(localAci)
@@ -478,6 +524,140 @@ class RecipientDatabaseTest {
assert(changeNumberListener.numberChangeWasEnqueued)
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_merge_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
assertNotEquals(threadIdAci, threadIdE164)
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
val identityKeyAci: IdentityKey = identityKey(1)
val identityKeyE164: IdentityKey = identityKey(2)
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
val profile1: NotificationProfile = notificationProfile(name = "Test")
val profile2: NotificationProfile = notificationProfile(name = "Test2")
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
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)!!
assertEquals(recipientIdAci, retrievedId)
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
assertEquals(retrievedId, existingE164Recipient.id)
// Thread validation
assertEquals(threadIdAci, retrievedThreadId)
Assert.assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
Assert.assertNull(threadDatabase.getThreadRecord(threadIdE164))
// SMS validation
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
assertEquals(retrievedThreadId, sms3.threadId)
// MMS validation
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
assertEquals(retrievedThreadId, mms3.threadId)
// Mention validation
val mention1: MentionModel = getMention(mmsId1)
assertEquals(retrievedId, mention1.recipientId)
assertEquals(retrievedThreadId, mention1.threadId)
val mention2: MentionModel = getMention(mmsId2)
assertEquals(retrievedId, mention2.recipientId)
assertEquals(retrievedThreadId, mention2.threadId)
// Group receipt validation
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
assertEquals(retrievedId, groupReceipts[0].recipientId)
assertEquals(retrievedId, groupReceipts[1].recipientId)
// Identity validation
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
Assert.assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
Assert.assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
// Reaction validation
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
assertEquals(1, reactionsSms.size)
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
assertEquals(1, reactionsMms.size)
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
// Notification Profile validation
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
MatcherAssert.assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
MatcherAssert.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)!!
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
}
// ==============================================================
// Misc
// ==============================================================
@@ -528,6 +708,53 @@ class RecipientDatabaseTest {
}
}
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
}
private fun identityKey(value: Byte): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
return IdentityKey(bytes)
}
private fun notificationProfile(name: String): NotificationProfile {
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
}
private fun groupMasterKey(value: Byte): GroupMasterKey {
val bytes = ByteArray(32)
bytes[0] = value
return GroupMasterKey(bytes)
}
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
return DecryptedGroup.newBuilder()
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
.build()
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
)
}
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,
val threadId: Long
)
private class ChangeNumberListener {
var numberChangeWasEnqueued = false

View File

@@ -1,271 +0,0 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
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
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_merges {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var identityDatabase: IdentityDatabase
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
private lateinit var groupDatabase: GroupDatabase
private lateinit var threadDatabase: ThreadDatabase
private lateinit var smsDatabase: MessageDatabase
private lateinit var mmsDatabase: MessageDatabase
private lateinit var sessionDatabase: SessionDatabase
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())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
identityDatabase = SignalDatabase.identities
groupReceiptDatabase = SignalDatabase.groupReceipts
groupDatabase = SignalDatabase.groups
threadDatabase = SignalDatabase.threads
smsDatabase = SignalDatabase.sms
mmsDatabase = SignalDatabase.mms
sessionDatabase = SignalDatabase.sessions
mentionDatabase = SignalDatabase.mentions
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
distributionListDatabase = SignalDatabase.distributionLists
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
assertNotEquals(threadIdAci, threadIdE164)
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
val identityKeyAci: IdentityKey = identityKey(1)
val identityKeyE164: IdentityKey = identityKey(2)
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
val profile1: NotificationProfile = notificationProfile(name = "Test")
val profile2: NotificationProfile = notificationProfile(name = "Test2")
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
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)!!
assertEquals(recipientIdAci, retrievedId)
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
assertEquals(retrievedId, existingE164Recipient.id)
// Thread validation
assertEquals(threadIdAci, retrievedThreadId)
assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
assertNull(threadDatabase.getThreadRecord(threadIdE164))
// SMS validation
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
assertEquals(retrievedThreadId, sms3.threadId)
// MMS validation
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
assertEquals(retrievedThreadId, mms3.threadId)
// Mention validation
val mention1: MentionModel = getMention(mmsId1)
assertEquals(retrievedId, mention1.recipientId)
assertEquals(retrievedThreadId, mention1.threadId)
val mention2: MentionModel = getMention(mmsId2)
assertEquals(retrievedId, mention2.recipientId)
assertEquals(retrievedThreadId, mention2.threadId)
// Group receipt validation
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
assertEquals(retrievedId, groupReceipts[0].recipientId)
assertEquals(retrievedId, groupReceipts[1].recipientId)
// Identity validation
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
// Reaction validation
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
assertEquals(1, reactionsSms.size)
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
assertEquals(1, reactionsMms.size)
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
// Notification Profile validation
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
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
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
}
private fun identityKey(value: Byte): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
return IdentityKey(bytes)
}
private fun groupMasterKey(value: Byte): GroupMasterKey {
val bytes = ByteArray(32)
bytes[0] = value
return GroupMasterKey(bytes)
}
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
return DecryptedGroup.newBuilder()
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
.build()
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
)
}
}
private fun notificationProfile(name: String): NotificationProfile {
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,
val threadId: Long
)
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val E164_A = "+12221234567"
val E164_B = "+13331234567"
}
}

View File

@@ -0,0 +1,206 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_processCdsV2Result {
private lateinit var recipientDatabase: RecipientDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
ensureDbEmpty()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
@Test
fun processCdsV2Result_noMatch() {
// Note that we haven't inserted any test data
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(resultId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_fullMatch() {
val inputId: RecipientId = insert(E164_A, PNI_A, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyE164Matches() {
val inputId: RecipientId = insert(E164_A, null, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_e164AndPniMatches() {
val inputId: RecipientId = insert(E164_A, PNI_A, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_e164AndAciMatches() {
val inputId: RecipientId = insert(E164_A, null, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyPniMatches() {
val inputId: RecipientId = insert(null, PNI_A, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_pniAndAciMatches() {
val inputId: RecipientId = insert(null, PNI_A, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyAciMatches() {
val inputId: RecipientId = insert(null, null, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientDatabase.TABLE_NAME,
null,
contentValuesOf(
RecipientDatabase.PHONE to e164,
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientDatabase.PNI_COLUMN to pni?.toString(),
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun require(id: RecipientId): IdRecord {
return get(id)!!
}
private fun get(id: RecipientId): IdRecord? {
SignalDatabase.rawDatabase
.select(RecipientDatabase.ID, RecipientDatabase.PHONE, RecipientDatabase.SERVICE_ID, RecipientDatabase.PNI_COLUMN)
.from(RecipientDatabase.TABLE_NAME)
.where("${RecipientDatabase.ID} = ?", id)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
IdRecord(
id = RecipientId.from(cursor.requireLong(RecipientDatabase.ID)),
e164 = cursor.requireString(RecipientDatabase.PHONE),
sid = ServiceId.parseOrNull(cursor.requireString(RecipientDatabase.SERVICE_ID)),
pni = PNI.parseOrNull(cursor.requireString(RecipientDatabase.PNI_COLUMN))
)
} else {
null
}
}
}
private fun ensureDbEmpty() {
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME} WHERE ${RecipientDatabase.DISTRIBUTION_LIST_ID} IS NULL ", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
private data class IdRecord(
val id: RecipientId,
val e164: String?,
val sid: ServiceId?,
val pni: PNI?,
)
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}
}

View File

@@ -1,21 +1,41 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import junit.framework.TestCase.assertNull
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class StorySendsDatabaseTest {
private val distributionId1 = DistributionId.from(UUID.randomUUID())
private val distributionId2 = DistributionId.from(UUID.randomUUID())
private val distributionId3 = DistributionId.from(UUID.randomUUID())
private lateinit var distributionList1: DistributionListId
private lateinit var distributionList2: DistributionListId
private lateinit var distributionList3: DistributionListId
private lateinit var distributionListRecipient1: Recipient
private lateinit var distributionListRecipient2: Recipient
private lateinit var distributionListRecipient3: Recipient
private lateinit var recipients1to10: List<RecipientId>
private lateinit var recipients11to20: List<RecipientId>
private lateinit var recipients6to15: List<RecipientId>
@@ -31,22 +51,41 @@ class StorySendsDatabaseTest {
fun setup() {
storySends = SignalDatabase.storySends
messageId1 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES)
messageId2 = MmsHelper.insert(storyType = StoryType.STORY_WITH_REPLIES)
messageId3 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES)
recipients1to10 = makeRecipients(10)
recipients11to20 = makeRecipients(10)
distributionList1 = SignalDatabase.distributionLists.createList("1", emptyList(), distributionId = distributionId1)!!
distributionList2 = SignalDatabase.distributionLists.createList("2", emptyList(), distributionId = distributionId2)!!
distributionList3 = SignalDatabase.distributionLists.createList("3", emptyList(), distributionId = distributionId3)!!
distributionListRecipient1 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList1))
distributionListRecipient2 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList2))
distributionListRecipient3 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList3))
messageId1 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
)
messageId2 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITH_REPLIES,
)
messageId3 = MmsHelper.insert(
recipient = distributionListRecipient3,
storyType = StoryType.STORY_WITHOUT_REPLIES,
)
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)
recipients6to10 = recipients1to10.takeLast(5)
}
@Test
fun getRecipientsToSendTo_noOverlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients11to20, 200, true)
storySends.insert(messageId3, recipients1to10, 300, false)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true)
@@ -60,8 +99,8 @@ class StorySendsDatabaseTest {
@Test
fun getRecipientsToSendTo_overlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients6to15, 100, true)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients6to15, 100, true, distributionId2)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
@@ -78,9 +117,9 @@ class StorySendsDatabaseTest {
val recipient1 = recipients1to10.first()
val recipient2 = recipients11to20.first()
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false)
storySends.insert(messageId2, listOf(recipient1), 100, true)
storySends.insert(messageId3, listOf(recipient2), 100, true)
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false, distributionId1)
storySends.insert(messageId2, listOf(recipient1), 100, true, distributionId2)
storySends.insert(messageId3, listOf(recipient2), 100, true, distributionId3)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
@@ -97,8 +136,8 @@ class StorySendsDatabaseTest {
@Test
fun getRecipientsToSendTo_overlapWithEarlierMessage() {
storySends.insert(messageId1, recipients6to15, 100, true)
storySends.insert(messageId2, recipients1to10, 100, false)
storySends.insert(messageId1, recipients6to15, 100, true, distributionId1)
storySends.insert(messageId2, recipients1to10, 100, false, distributionId2)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false)
@@ -112,9 +151,9 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_noOverlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients11to20, 200, true)
storySends.insert(messageId3, recipients1to10, 300, false)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -128,8 +167,8 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId2, recipients6to15, 200, true)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -143,10 +182,10 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
storySends.insert(messageId2, recipients6to15, 200, true)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -156,7 +195,7 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithReplies() {
storySends.insert(messageId2, recipients1to10, 200, true)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val canReply = storySends.canReply(recipients1to10[0], 200)
@@ -165,7 +204,7 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithoutReplies() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val canReply = storySends.canReply(recipients1to10[0], 200)
@@ -174,8 +213,8 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithAndWithoutRepliesOverlap() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId2, recipients6to10, 200, true)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients6to10, 200, true, distributionId2)
val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200)
val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200)
@@ -184,6 +223,238 @@ class StorySendsDatabaseTest {
assertThat(message2RecipientCanReply, `is`(true))
}
@Test
fun givenASingleStory_whenIGetFullSentStorySyncManifest_thenIExpectNotNull() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)
assertNotNull(manifest)
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNull() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, false, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)
assertNull(manifest)
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectOneManifestPerRecipient() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
assertEquals(recipients1to10, manifest.entries.map { it.recipientId })
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectTwoListsPerRecipient() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
manifest.entries.forEach { entry ->
assertEquals(listOf(distributionId1, distributionId2), entry.distributionLists)
}
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectAllRecipientsCanReply() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
manifest.entries.forEach { entry ->
assertTrue(entry.allowedToReply)
}
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
assertNotNull(manifest)
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId1, recipients11to20, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
assertEquals(recipients1to10.toHashSet(), recipientIds)
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectOnlyRecipientsThatHadStory1() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
val manifestRecipients = results.entries.map { it.recipientId }
assertEquals(recipients1to10, manifestRecipients)
}
@Test
fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId2)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
assertTrue(results.entries.all { it.allowedToReply })
}
@Test
fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val expected = storySends.getFullSentStorySyncManifest(messageId1, 200)
val emptyManifest = SentStorySyncManifest(emptyList())
storySends.applySentStoryManifest(emptyManifest, 200)
val result = storySends.getFullSentStorySyncManifest(messageId1, 200)
assertEquals(expected, result)
}
@Test
fun givenAnIdenticalManifest_whenIApplyRemoteManifest_thenNothingChanges() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val expected = storySends.getFullSentStorySyncManifest(messageId4, 200)
storySends.applySentStoryManifest(expected!!, 200)
val result = storySends.getFullSentStorySyncManifest(messageId4, 200)
assertEquals(expected, result)
}
@Test
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
val messageId5 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
storySends.applySentStoryManifest(remote, 200)
assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete)
}
@Test
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectSharedMessageToNotBeMarkedRemoteDeleted() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
val messageId5 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
storySends.applySentStoryManifest(remote, 200)
assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete)
}
@Test
fun givenNoLocalEntries_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatch() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 2000
)
val remote = SentStorySyncManifest(
recipients1to10.map {
SentStorySyncManifest.Entry(
recipientId = it,
allowedToReply = true,
distributionLists = listOf(distributionId1)
)
}
)
storySends.applySentStoryManifest(remote, 2000)
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
assertEquals(remote, local)
}
@Test
fun givenNonStoryMessageAtSentTimestamp_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatchAndNoCrashes() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 2000
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients1to10.first()),
sentTimeMillis = 2000
)
val remote = SentStorySyncManifest(
recipients1to10.map {
SentStorySyncManifest.Entry(
recipientId = it,
allowedToReply = true,
distributionLists = listOf(distributionId1)
)
}
)
storySends.applySentStoryManifest(remote, 2000)
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
assertEquals(remote, local)
}
private fun makeRecipients(count: Int): List<RecipientId> {
return (1..count).map {
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))

View File

@@ -0,0 +1,119 @@
package org.thoughtcrime.securesms.testing
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.ExternalResource
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationRepository
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.UUID
/**
* Test rule to use that sets up the application in a mostly registered state. Enough so that most
* activities should be launchable directly.
*
* To use: `@get:Rule val harness = SignalActivityRule()`
*/
class SignalActivityRule : ExternalResource() {
val application: Application = ApplicationDependencies.getApplication()
lateinit var context: Context
private set
lateinit var self: Recipient
private set
lateinit var others: List<RecipientId>
private set
override fun before() {
context = InstrumentationRegistry.getInstrumentation().targetContext
self = setupSelf()
others = setupOthers()
}
private fun setupSelf(): Recipient {
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
preferences.edit().putBoolean("passphrase_initialized", true).commit()
val registrationRepository = RegistrationRepository(application)
registrationRepository.registerAccountWithoutRegistrationLock(
RegistrationData(
code = "123123",
e164 = "+15554045550101",
password = Util.getSecret(18),
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15554045550101"),
fcmToken = null
),
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false)
).blockingGet()
SignalStore.kbsValues().optOut()
RegistrationUtil.maybeMarkRegistrationComplete(application)
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
return Recipient.self()
}
private fun setupOthers(): List<RecipientId> {
val others = mutableListOf<RecipientId>()
for (i in 0..4) {
val aci = ACI.from(UUID.randomUUID())
val recipientId = RecipientId.from(aci, "+1555555101$i")
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
others += recipientId
}
return others
}
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit): ActivityScenario<T> {
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
}
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
}
fun getIdentity(recipient: Recipient): IdentityKey {
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0))
}
fun setVerified(recipient: Recipient, status: IdentityDatabase.VerifiedStatus) {
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipient.id, getIdentity(recipient), IdentityDatabase.VerifiedStatus.VERIFIED)
}
}

View File

@@ -403,9 +403,17 @@
<activity
android:name=".stories.viewer.StoryViewerActivity"
android:screenOrientation="portrait"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.DarkNoActionBar.StoryViewer"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
android:launchMode="singleTask"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:parentActivityName=".MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -463,6 +471,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".DeviceActivity"
android:screenOrientation="portrait"
android:label="@string/AndroidManifest__linked_devices"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -540,6 +549,7 @@
<activity android:name=".blocked.BlockedUsersActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".scribbles.ImageEditorStickerSelectActivity"
@@ -650,7 +660,7 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService"/>
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
<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"/>
@@ -704,7 +714,9 @@
<service android:name=".service.GenericForegroundService"/>
<service android:name=".gcm.FcmFetchService" />
<service android:name=".gcm.FcmFetchBackgroundService" />
<service android:name=".gcm.FcmFetchForegroundService" />
<service android:name=".gcm.FcmReceiveService">
<intent-filter>

View File

@@ -60,7 +60,7 @@ 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.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
@@ -198,7 +198,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveReleaseChannelJob::enqueue)
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
@@ -218,7 +218,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getFrameRateTracker().start();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
ApplicationDependencies.getDeadlockDetector().start();
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary();
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();

View File

@@ -105,5 +105,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
boolean onUrlClicked(@NonNull String url);
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.lifecycle.LifecycleOwner;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
@@ -11,7 +12,8 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull ThreadRecord thread,
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations);

View File

@@ -83,7 +83,8 @@ public final class BlockUnblockDialog {
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages
: R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_send_you_messages);
if (onBlockAndReportSpam != null) {
builder.setNeutralButton(android.R.string.cancel, null);
@@ -128,7 +129,8 @@ public final class BlockUnblockDialog {
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other
: R.string.BlockUnblockDialog_you_will_be_able_to_message_each_other);
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
}

View File

@@ -25,11 +25,11 @@ import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;

View File

@@ -14,36 +14,34 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.qr.ScanningThread;
import org.signal.qr.QrScannerView;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.ViewUtil;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public class DeviceAddFragment extends LoggingFragment {
private ViewGroup container;
private LinearLayout overlay;
private ImageView devicesImage;
private CameraView scannerView;
private ScanningThread scanningThread;
private ScanListener scanListener;
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private ImageView devicesImage;
private ScanListener scanListener;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
this.overlay = this.container.findViewById(R.id.overlay);
this.scannerView = this.container.findViewById(R.id.scanner);
this.devicesImage = this.container.findViewById(R.id.devices);
ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
this.overlay.setOrientation(LinearLayout.HORIZONTAL);
} else {
this.overlay.setOrientation(LinearLayout.VERTICAL);
}
QrScannerView scannerView = container.findViewById(R.id.scanner);
this.devicesImage = container.findViewById(R.id.devices);
ViewCompat.setTransitionName(devicesImage, "devices");
if (Build.VERSION.SDK_INT >= 21) {
this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@TargetApi(21)
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
@@ -59,52 +57,30 @@ public class DeviceAddFragment extends LoggingFragment {
});
}
return this.container;
scannerView.start(getViewLifecycleOwner(), FeatureFlags.useQrLegacyScan());
lifecycleDisposable.bindTo(getViewLifecycleOwner());
Disposable qrDisposable = scannerView
.getQrData()
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(qrData -> {
if (scanListener != null) {
scanListener.onQrDataFound(qrData);
}
});
lifecycleDisposable.add(qrDisposable);
return container;
}
@Override
public void onResume() {
super.onResume();
this.scanningThread = new ScanningThread();
this.scanningThread.setScanListener(scanListener);
this.scannerView.onResume();
this.scannerView.setPreviewCallback(scanningThread);
this.scanningThread.start();
}
@Override
public void onPause() {
super.onPause();
this.scannerView.onPause();
this.scanningThread.stopScanning();
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfiguration) {
super.onConfigurationChanged(newConfiguration);
this.scannerView.onPause();
if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
overlay.setOrientation(LinearLayout.HORIZONTAL);
} else {
overlay.setOrientation(LinearLayout.VERTICAL);
}
this.scannerView.onResume();
this.scannerView.setPreviewCallback(scanningThread);
}
public ImageView getDevicesImage() {
return devicesImage;
}
public void setScanListener(ScanListener scanListener) {
this.scanListener = scanListener;
if (this.scanningThread != null) {
this.scanningThread.setScanListener(scanListener);
}
}
}

View File

@@ -9,6 +9,7 @@ import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
public class DeviceLinkFragment extends Fragment implements View.OnClickListener {
@@ -21,6 +22,7 @@ public class DeviceLinkFragment extends Fragment implements View.OnClickListener
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
this.container.findViewById(R.id.link_device).setOnClickListener(this);
ViewCompat.setTransitionName(container.findViewById(R.id.devices), "devices");
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
container.setOrientation(LinearLayout.HORIZONTAL);

View File

@@ -171,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_WELCOME_PUSH_SCREEN;
} else if (SignalStore.storageService().needsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userHasSkippedOrForgottenPin()) {
return STATE_CREATE_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
@@ -190,6 +192,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
}
private boolean userHasSkippedOrForgottenPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut() && SignalStore.kbsValues().isPinForgottenOrSkipped();
}
private boolean userMustSetProfileName() {
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
}

View File

@@ -44,6 +44,8 @@ import androidx.window.DisplayFeature;
import androidx.window.FoldingFeature;
import androidx.window.WindowLayoutInfo;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@@ -112,6 +114,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private boolean hasWarnedAboutBluetooth;
private androidx.window.WindowManager windowManager;
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
private ThrottledDebouncer requestNewSizesThrottle;
@@ -686,6 +689,17 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
enableVideoIfAvailable = false;
handleSetMuteVideo(false);
}
if (event.getBluetoothPermissionDenied() && !hasWarnedAboutBluetooth && !isFinishing()) {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.WebRtcCallActivity__bluetooth_permission_denied)
.setMessage(R.string.WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
.setPositiveButton(R.string.WebRtcCallActivity__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(this)))
.setNegativeButton(R.string.WebRtcCallActivity__not_now, null)
.show();
hasWarnedAboutBluetooth = true;
}
}
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {

View File

@@ -183,7 +183,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
private val isNewGroup: Boolean,
private val groupAvatarMedia: Media?
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val viewModel = if (groupId == null && !isNewGroup) {
SelfAvatarPickerViewModel(repository)
} else if (groupId == null) {

View File

@@ -33,7 +33,7 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
}
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}

View File

@@ -20,7 +20,7 @@ class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel()
fun getCurrentAvatar() = store.state.currentAvatar
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
}
}

View File

@@ -197,7 +197,9 @@ public class FullBackupExporter extends FullBackupBase {
throwIfCanceled(cancellationSignal);
if (avatar != null) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
try (InputStream inputStream = avatar.getInputStream()) {
outputStream.write(avatar.getFilename(), inputStream, avatar.getLength());
}
}
}
@@ -377,6 +379,7 @@ public class FullBackupExporter extends FullBackupBase {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
inputStream.close();
}
} catch (IOException e) {
Log.w(TAG, e);
@@ -395,8 +398,9 @@ public class FullBackupExporter extends FullBackupBase {
if (!TextUtils.isEmpty(data) && size > 0) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
outputStream.writeSticker(rowId, inputStream, size);
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
outputStream.writeSticker(rowId, inputStream, size);
}
}
} catch (IOException e) {
Log.w(TAG, e);

View File

@@ -87,8 +87,6 @@ class BadgeImageView @JvmOverloads constructor(
.downsample(DownsampleStrategy.NONE)
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), ScreenDensity.getBestDensityBucketForDevice(), ThemeUtil.isDarkTheme(context)))
.into(this)
isClickable = true
} else {
glideRequests
.clear(this)

View File

@@ -1,41 +1,83 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ProfileUtil
import java.io.IOException
class BadgeRepository(context: Context) {
companion object {
private val TAG = Log.tag(BadgeRepository::class.java)
}
private val context = context.applicationContext
/**
* Sets the visibility for each badge on a user's profile, and uploads them to the server.
* Does not write to the local database. The caller must either do that themselves or schedule
* a refresh own profile job.
*
* @return A list of the badges, properly modified to either visible or not visible, according to user preferences.
*/
@Throws(IOException::class)
@WorkerThread
fun setVisibilityForAllBadgesSync(
displayBadgesOnProfile: Boolean,
selfBadges: List<Badge>
): List<Badge> {
Log.d(TAG, "[setVisibilityForAllBadgesSync] Setting badge visibility...", true)
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
Log.d(TAG, "[setVisibilityForAllBadgesSync] Uploading profile...", true)
ProfileUtil.uploadProfileWithBadges(context, badges)
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
recipientDatabase.markNeedsSync(Recipient.self().id)
Log.d(TAG, "[setVisibilityForAllBadgesSync] Requesting data change sync...", true)
StorageSyncHelper.scheduleSyncForDataChange()
return badges
}
fun setVisibilityForAllBadges(
displayBadgesOnProfile: Boolean,
selfBadges: List<Badge> = Recipient.self().badges
): Completable = Completable.fromAction {
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
setVisibilityForAllBadgesSync(displayBadgesOnProfile, selfBadges)
ProfileUtil.uploadProfileWithBadges(context, badges)
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
recipientDatabase.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
recipientDatabase.setBadges(Recipient.self().id, badges)
Log.d(TAG, "[setVisibilityForAllBadges] Enqueueing profile refresh...", true)
ApplicationDependencies.getJobManager()
.startChain(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
.enqueue()
}.subscribeOn(Schedulers.io())
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
val badges = Recipient.self().badges
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
Log.d(TAG, "[setFeaturedBadge] Uploading profile with reordered badges...", true)
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
Log.d(TAG, "[setFeaturedBadge] Enqueueing profile refresh...", true)
ApplicationDependencies.getJobManager()
.startChain(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
.enqueue()
}.subscribeOn(Schedulers.io())
}

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.util.ScreenDensity
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import java.math.BigDecimal
import java.sql.Timestamp
import java.util.concurrent.TimeUnit
object Badges {
@@ -93,7 +94,8 @@ object Badges {
Uri.parse(badge.imageUrl),
badge.imageDensity,
badge.expiration,
badge.visible
badge.visible,
0L
)
}
@@ -122,7 +124,8 @@ object Badges {
uriAndDensity.first(),
uriAndDensity.second(),
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
serviceBadge.isVisible
serviceBadge.isVisible,
TimeUnit.SECONDS.toMillis(serviceBadge.duration)
)
}
}

View File

@@ -13,6 +13,7 @@ import com.google.android.material.button.MaterialButton
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.gifts.Gifts.formatExpiry
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.GlideRequests
@@ -50,7 +51,7 @@ class GiftMessageView @JvmOverloads constructor(
fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
titleView.setText(R.string.GiftMessageView__gift_badge)
descriptionView.text = resources.getQuantityString(R.plurals.GiftMessageView__lasts_for_d_months, 1, 1)
descriptionView.text = giftBadge.formatExpiry(context)
actionView.icon = null
actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
actionView.isEnabled = true

View File

@@ -1,5 +1,9 @@
package org.thoughtcrime.securesms.badges.gifts
import android.content.Context
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
@@ -7,6 +11,8 @@ import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import java.lang.Integer.min
import java.util.concurrent.TimeUnit
/**
* Helper object for Gift badges
@@ -45,4 +51,36 @@ object Gifts {
giftBadge
)
}
/**
* @return the expiration time from the redemption token, in UNIX epoch seconds.
*/
private fun GiftBadge.getExpiry(): Long {
return try {
ReceiptCredentialPresentation(redemptionToken.toByteArray()).receiptExpirationTime
} catch (e: InvalidInputException) {
return 0L
}
}
fun GiftBadge.formatExpiry(context: Context): String {
val expiry = getExpiry()
val timeRemaining = TimeUnit.SECONDS.toMillis(expiry) - System.currentTimeMillis()
if (timeRemaining <= 0) {
return context.getString(R.string.Gifts__expired)
}
val days = TimeUnit.MILLISECONDS.toDays(timeRemaining).toInt()
if (days > 0) {
return context.resources.getQuantityString(R.plurals.Gifts__d_days_remaining, days, days)
}
val hours = TimeUnit.MILLISECONDS.toHours(timeRemaining).toInt()
if (hours > 0) {
return context.resources.getQuantityString(R.plurals.Gifts__d_hours_remaining, hours, hours)
}
val minutes = min(1, TimeUnit.MILLISECONDS.toMinutes(timeRemaining).toInt())
return context.resources.getQuantityString(R.plurals.Gifts__d_minutes_remaining, minutes, minutes)
}
}

View File

@@ -9,7 +9,7 @@ interface OpenableGift {
/**
* Returns a projection to draw a top, or null to not do so.
*/
fun getOpenableGiftProjection(): Projection?
fun getOpenableGiftProjection(isAnimating: Boolean): Projection?
/**
* Returns a unique id assosicated with this gift.

View File

@@ -6,9 +6,13 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.provider.Settings
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AnticipateInterpolator
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.graphics.toRect
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import androidx.core.view.children
@@ -33,18 +37,22 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
private val animationState = mutableMapOf<Long, GiftAnimationState>()
private val rect = RectF()
private val lineWidth = DimensionUnit.DP.toPixels(24f).toInt()
private val lineWidth = DimensionUnit.DP.toPixels(16f).toInt()
private val boxPaint = Paint().apply {
isAntiAlias = true
color = ContextCompat.getColor(context, R.color.core_ultramarine)
}
private val ribbonPaint = Paint().apply {
private val bowPaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
}
private val bowWidth = DimensionUnit.DP.toPixels(80f)
private val bowHeight = DimensionUnit.DP.toPixels(60f)
private val bowDrawable: Drawable = AppCompatResources.getDrawable(context, R.drawable.ic_gift_bow)!!
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
animationState.clear()
@@ -62,19 +70,19 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
val projection = child.getOpenableGiftProjection()
val projection = child.getOpenableGiftProjection(false)
if (projection != null) {
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
child.setOpenGiftCallback {
child.clearOpenGiftCallback()
val proj = it.getOpenableGiftProjection()
if (proj != null) {
messageIdsOpenedThisSession.add(it.getGiftId())
startOpenAnimation(it)
parent.invalidate()
}
child.setOpenGiftCallback {
child.clearOpenGiftCallback()
val proj = it.getOpenableGiftProjection(true)
if (proj != null) {
messageIdsOpenedThisSession.add(it.getGiftId())
startOpenAnimation(it)
parent.invalidate()
}
}
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
drawGiftBox(c, projection)
drawGiftBow(c, projection)
} else {
@@ -124,7 +132,7 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
projection.y + projection.height
)
canvas.drawRect(rect, ribbonPaint)
canvas.drawRect(rect, bowPaint)
rect.set(
projection.x,
@@ -133,18 +141,23 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
projection.y + (projection.height / 2) + lineWidth / 2
)
canvas.drawRect(rect, ribbonPaint)
canvas.drawRect(rect, bowPaint)
}
private fun drawGiftBow(canvas: Canvas, projection: Projection) {
rect.set(
projection.x + (projection.width / 2) - lineWidth,
projection.y + (projection.height / 2) - lineWidth,
projection.x + (projection.width / 2) + lineWidth,
projection.y + (projection.height / 2) + lineWidth
projection.x + (projection.width / 2) - (bowWidth / 2),
projection.y,
projection.x + (projection.width / 2) + (bowWidth / 2),
projection.y + bowHeight
)
canvas.drawRect(rect, ribbonPaint)
val padTop = (projection.height - rect.height()) * (48f / 89f)
bowDrawable.bounds = rect.toRect()
canvas.withTranslation(y = padTop) {
bowDrawable.draw(canvas)
}
}
private fun startShakeAnimation(child: OpenableGift) {
@@ -155,14 +168,14 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
}
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long) {
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long, val duration: Long) {
/**
* Shakes the gift box to the left and right, slightly revealing the contents underneath.
* Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
* following behind.
*/
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, SHAKE_DURATION_MILLIS) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
canvas.withTranslation(x = getTranslation(progress).toFloat()) {
drawBox(canvas, projection)
@@ -181,12 +194,15 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
}
}
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, OPEN_DURATION_MILLIS) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(300f))
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(161f))
canvas.translate(evaluatedValue, -evaluatedValue)
val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(progress)
val evaluatedY = EVALUATOR.evaluate(interpolatedY, 0f, DimensionUnit.DP.toPixels(355f))
canvas.translate(evaluatedValue, evaluatedY)
drawBox(canvas, projection)
drawBow(canvas, projection)
@@ -194,7 +210,7 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
}
fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
val projection = openableGift.getOpenableGiftProjection() ?: return false
val projection = openableGift.getOpenableGiftProjection(true) ?: return false
if (animatorDurationScale <= 0f) {
update(canvas, projection, 0f, 0f, drawBox, drawBow)
@@ -203,8 +219,8 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
}
val currentFrameTime = System.currentTimeMillis()
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (DURATION_MILLIS.toFloat() * animatorDurationScale))
val progress = (currentFrameTime - startTime) / (DURATION_MILLIS.toFloat() * animatorDurationScale)
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (duration.toFloat() * animatorDurationScale))
val progress = (currentFrameTime - startTime) / (duration.toFloat() * animatorDurationScale)
if (progress > 1f) {
update(canvas, projection, 1f, 1f, drawBox, drawBow)
@@ -228,10 +244,12 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration
}
companion object {
private val TRANSLATION_Y_INTERPOLATOR = AnticipateInterpolator(3f)
private val INTERPOLATOR = AccelerateDecelerateInterpolator()
private val EVALUATOR = FloatEvaluator()
private const val DURATION_MILLIS = 1000
private const val SHAKE_DURATION_MILLIS = 1000L
private const val OPEN_DURATION_MILLIS = 700L
private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
}
}

View File

@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -67,10 +68,12 @@ class GiftFlowConfirmationFragment :
private val lifecycleDisposable = LifecycleDisposable()
private var errorDialog: DialogInterface? = null
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
private val debouncer = Debouncer(100L)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
RecipientPreference.register(adapter)
@@ -85,6 +88,11 @@ class GiftFlowConfirmationFragment :
.setCancelable(false)
.create()
verifyingRecipientDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.verifying_recipient_payment_dialog)
.setCancelable(false)
.create()
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
@@ -122,10 +130,17 @@ class GiftFlowConfirmationFragment :
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
if (state.stage == GiftFlowState.Stage.RECIPIENT_VERIFICATION) {
debouncer.publish { verifyingRecipientDonationPaymentDialog.show() }
} else {
debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss()
}
if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
processingDonationPaymentDialog.dismiss()
}
textInputViewHolder.bind(
@@ -175,6 +190,8 @@ class GiftFlowConfirmationFragment :
super.onDestroyView()
textInputViewHolder.onDetachedFromWindow()
processingDonationPaymentDialog.dismiss()
debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss()
}
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {

View File

@@ -55,6 +55,7 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeSelf = false,
includeHeader = true,
mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
)

View File

@@ -20,8 +20,9 @@ data class GiftFlowState(
enum class Stage {
INIT,
READY,
RECIPIENT_VERIFICATION,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
FAILURE
FAILURE;
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -128,9 +129,20 @@ class GiftFlowViewModel(
fun requestTokenFromGooglePay(label: String) {
val giftLevel = store.state.giftLevel ?: return
val giftPrice = store.state.giftPrices[store.state.currency] ?: return
val giftRecipient = store.state.recipient?.id ?: return
this.giftToPurchase = Gift(giftLevel, giftPrice)
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) }
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
},
onError = this::onPaymentFlowError
)
}
fun onActivityResult(
@@ -153,16 +165,7 @@ class GiftFlowViewModel(
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onError = this@GiftFlowViewModel::onPaymentFlowError,
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!))
@@ -185,6 +188,17 @@ class GiftFlowViewModel(
)
}
private fun onPaymentFlowError(throwable: Throwable) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
}
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
@@ -225,7 +239,7 @@ class GiftFlowViewModel(
private val repository: GiftFlowRepository,
private val donationPaymentRepository: DonationPaymentRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
GiftFlowViewModel(
repository,

View File

@@ -12,6 +12,7 @@ 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 org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
/**
* A line item for gifts, displayed in the Gift flow's start and confirmation fragments.
@@ -40,13 +41,18 @@ object GiftRowItem {
badgeView.setBadge(model.giftBadge)
titleView.text = model.giftBadge.name
taglineView.setText(R.string.GiftRowItem__send_a_gift_badge)
priceView.text = FiatMoneyUtil.format(
val price = FiatMoneyUtil.format(
context.resources,
model.price,
FiatMoneyUtil.formatOptions()
.trimZerosAfterDecimal()
.withDisplayTime(false)
)
val duration = TimeUnit.MILLISECONDS.toDays(model.giftBadge.duration)
priceView.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
}
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.badges.gifts.thanks
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
@@ -48,7 +49,8 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
lifecycleDisposable += Recipient.observable(recipientId).subscribe {
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += Recipient.observable(recipientId).observeOn(AndroidSchedulers.mainThread()).subscribe {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}

View File

@@ -6,7 +6,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveDataReactiveStreams
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -105,7 +104,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
onRedemptionError(donationError)
}
LiveDataReactiveStreams.fromPublisher(viewModel.state).observe(viewLifecycleOwner) { state ->
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}

View File

@@ -143,7 +143,7 @@ class ViewReceivedGiftViewModel(
private val repository: ViewGiftRepository,
private val badgeRepository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ViewReceivedGiftViewModel(sentFrom, messageId, repository, badgeRepository)) as T
}
}

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveDataReactiveStreams
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Handles all interactions for received gift badges.
@@ -45,6 +46,8 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
private val giftBadge: GiftBadge
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
private val lifecycleDisposable = LifecycleDisposable()
private val viewModel: ViewSentGiftViewModel by viewModels(
factoryProducer = { ViewSentGiftViewModel.Factory(sentTo, giftBadge, ViewGiftRepository()) }
)
@@ -52,7 +55,8 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
LiveDataReactiveStreams.fromPublisher(viewModel.state).observe(viewLifecycleOwner) { state ->
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}

View File

@@ -45,7 +45,7 @@ class ViewSentGiftViewModel(
private val giftBadge: GiftBadge,
private val repository: ViewGiftRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ViewSentGiftViewModel(sentFrom, giftBadge, repository)) as T
}
}

View File

@@ -35,10 +35,13 @@ data class Badge(
val imageDensity: String,
val expirationTimestamp: Long,
val visible: Boolean,
val duration: Long
) : Parcelable, Key {
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
fun isBoost(): Boolean = id == BOOST_BADGE_ID
fun isGift(): Boolean = id == GIFT_BADGE_ID
fun isSubscription(): Boolean = !isBoost() && !isGift()
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(id.toByteArray(Key.CHARSET))

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
@@ -11,9 +13,13 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
@@ -30,11 +36,13 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
@@ -55,6 +63,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
DSLSettingsText.from(
if (badge.isBoost()) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
} else if (declineCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(declineCode.mapToErrorStringResource()),
badge.name
)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
} else {
@@ -66,22 +80,33 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
space(DimensionUnit.DP.toPixels(68f).toInt())
space(DimensionUnit.DP.toPixels(92f).toInt())
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
onClick = {
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
}
)
} else {
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
}
primaryButton(
text = DSLSettingsText.from(
@@ -115,9 +140,16 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
}
companion object {
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build()
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()

View File

@@ -66,7 +66,7 @@ class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : Vi
}
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
}
}

View File

@@ -38,7 +38,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
}
}

View File

@@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null))
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null, null))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}

View File

@@ -91,7 +91,7 @@ class BadgesOverviewViewModel(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
}
}

View File

@@ -52,7 +52,7 @@ class ViewBadgeViewModel(
private val recipientId: RecipientId,
private val repository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
}
}

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Optional;
import java.util.function.Consumer;
@@ -69,6 +70,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
contactFilterView.focusAndShowKeyboard();
} else {
contactFilterView.setVisibility(View.GONE);
ViewUtil.hideKeyboard(this, contactFilterView);
}
});

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.blurhash;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -9,7 +12,7 @@ import java.util.Objects;
* A BlurHash is a compact string representation of a blurred image that we can use to show fast
* image previews.
*/
public class BlurHash {
public class BlurHash implements Parcelable {
private final String hash;
@@ -17,6 +20,20 @@ public class BlurHash {
this.hash = hash;
}
protected BlurHash(Parcel in) {
hash = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(hash);
}
@Override
public int describeContents() {
return 0;
}
public static @Nullable BlurHash parseOrNull(@Nullable String hash) {
if (Base83.isValid(hash)) {
return new BlurHash(hash);
@@ -40,4 +57,16 @@ public class BlurHash {
public int hashCode() {
return Objects.hash(hash);
}
public static final Creator<BlurHash> CREATOR = new Creator<BlurHash>() {
@Override
public BlurHash createFromParcel(Parcel in) {
return new BlurHash(in);
}
@Override
public BlurHash[] newArray(int size) {
return new BlurHash[size];
}
};
}

View File

@@ -179,9 +179,10 @@ public class InputPanel extends LinearLayout
long id,
@NonNull Recipient author,
@NonNull CharSequence body,
@NonNull SlideDeck attachments)
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null, null);
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null, null, quoteType);
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
@@ -256,7 +257,7 @@ public class InputPanel extends LinearLayout
public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions(), quoteView.getQuoteType()));
} else {
return Optional.empty();
}

View File

@@ -108,7 +108,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
}
private void updateKeyboardState() {
if (viewInset == 0 && Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) viewInset = getViewInset();
if (viewInset == 0 && Build.VERSION.SDK_INT >= 21) viewInset = getViewInset();
getWindowVisibleDisplayFrame(rect);
@@ -137,6 +137,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
if (Build.VERSION.SDK_INT >= 23 && getRootWindowInsets() != null) {
int bottomInset;
WindowInsets windowInsets = getRootWindowInsets();
if (Build.VERSION.SDK_INT >= 30) {
bottomInset = windowInsets.getInsets(WindowInsets.Type.navigationBars()).bottom;
} else {

View File

@@ -20,7 +20,11 @@ import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.imageview.ShapeableImageView;
import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
@@ -30,6 +34,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
@@ -74,29 +79,30 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
}
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private View quoteBarView;
private ImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private TextView attachmentNameView;
private ImageView dismissView;
private EmojiImageView missingStoryReaction;
private EmojiImageView storyReactionEmoji;
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private View quoteBarView;
private ShapeableImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private TextView attachmentNameView;
private ImageView dismissView;
private EmojiImageView missingStoryReaction;
private EmojiImageView storyReactionEmoji;
private long id;
private LiveRecipient author;
private CharSequence body;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
private MessageType messageType;
private int largeCornerRadius;
private int smallCornerRadius;
private CornerMask cornerMask;
private long id;
private LiveRecipient author;
private CharSequence body;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
private MessageType messageType;
private int largeCornerRadius;
private int smallCornerRadius;
private CornerMask cornerMask;
private QuoteModel.Type quoteType;
private int thumbHeight;
private int thumbWidth;
@@ -206,7 +212,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
boolean originalMissing,
@NonNull SlideDeck attachments,
@Nullable ChatColors chatColors,
@Nullable String storyReaction)
@Nullable String storyReaction,
@NonNull QuoteModel.Type quoteType)
{
if (this.author != null) this.author.removeForeverObserver(this);
@@ -214,10 +221,11 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
this.author = author.live();
this.body = body;
this.attachments = attachments;
this.quoteType = quoteType;
this.author.observeForever(this);
setQuoteAuthor(author);
setQuoteText(body, attachments, originalMissing, storyReaction);
setQuoteText(resolveBody(body, quoteType), attachments, originalMissing, storyReaction);
setQuoteAttachment(glideRequests, body, attachments, originalMissing);
setQuoteMissingFooter(originalMissing);
@@ -228,6 +236,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
}
private @Nullable CharSequence resolveBody(@Nullable CharSequence body, @NonNull QuoteModel.Type quoteType) {
return quoteType == QuoteModel.Type.GIFT_BADGE ? getContext().getString(R.string.QuoteView__gift) : body;
}
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius);
cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius);
@@ -301,7 +313,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
mediaDescriptionText.setText(R.string.QuoteView_no_longer_available);
if (storyReaction != null) {
missingStoryReaction.setVisibility(View.VISIBLE);
missingStoryReaction.setImageEmoji(body);
missingStoryReaction.setImageEmoji(storyReaction);
} else {
missingStoryReaction.setVisibility(View.GONE);
}
@@ -371,7 +383,11 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck, boolean originalMissing) {
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
thumbnailView.setPadding(0, 0, 0, 0);
if (!attachments.containsMediaSlide() && isStoryReply()) {
StoryTextPostModel model = getStoryTextPost(body);
@@ -386,6 +402,24 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
return;
}
if (quoteType == QuoteModel.Type.GIFT_BADGE) {
if (outgoing && !preview) {
int oneDp = (int) DimensionUnit.DP.toPixels(1);
thumbnailView.setPadding(oneDp, oneDp, oneDp, oneDp);
thumbnailView.setShapeAppearanceModel(buildShapeAppearanceForLayoutDirection());
}
attachmentVideoOverlayView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
thumbnailView.setVisibility(VISIBLE);
glideRequests.load(R.drawable.ic_gift_thumbnail)
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
return;
}
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
@@ -459,7 +493,26 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
return attachments.asAttachments();
}
public @NonNull QuoteModel.Type getQuoteType() {
return quoteType;
}
public @NonNull List<Mention> getMentions() {
return MentionAnnotation.getMentionsFromAnnotations(body);
}
private @NonNull ShapeAppearanceModel buildShapeAppearanceForLayoutDirection() {
int fourDp = (int) DimensionUnit.DP.toPixels(4);
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
return ShapeAppearanceModel.builder()
.setTopRightCorner(CornerFamily.ROUNDED, fourDp)
.setBottomRightCorner(CornerFamily.ROUNDED, fourDp)
.build();
} else {
return ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, fourDp)
.setBottomLeftCorner(CornerFamily.ROUNDED, fourDp)
.build();
}
}
}

View File

@@ -38,6 +38,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.qr.kitkat.QrCameraView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -228,7 +229,7 @@ public class CameraView extends ViewGroup {
listeners.add(listener);
}
public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) {
public void setPreviewCallback(final @NonNull QrCameraView.PreviewCallback previewCallback) {
enqueueTask(new PostInitializationTask<Void>() {
@Override
protected void onPostMain(Void avoid) {
@@ -243,7 +244,7 @@ public class CameraView extends ViewGroup {
final int rotation = getCameraPictureOrientation();
final Size previewSize = camera.getParameters().getPreviewSize();
if (data != null) {
previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation));
previewCallback.onPreviewFrame(new QrCameraView.PreviewFrame(data, previewSize.width, previewSize.height, rotation));
}
}
});
@@ -568,40 +569,6 @@ public class CameraView extends ViewGroup {
void onCameraStop();
}
public interface PreviewCallback {
void onPreviewFrame(@NonNull PreviewFrame frame);
}
public static class PreviewFrame {
private final @NonNull byte[] data;
private final int width;
private final int height;
private final int orientation;
private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) {
this.data = data;
this.width = width;
this.height = height;
this.orientation = orientation;
}
public @NonNull byte[] getData() {
return data;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getOrientation() {
return orientation;
}
}
private enum State {
PAUSED, RESUMED, ACTIVE
}

View File

@@ -8,4 +8,5 @@ public final class EmojiStrings {
public static final String AUDIO = "\uD83C\uDFA4";
public static final String FILE = "\uD83D\uDCCE";
public static final String STICKER = "\u2B50";
public static final String GIFT = "\uD83C\uDF81";
}

View File

@@ -65,6 +65,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
}
}
intent = intent.putExtra(START_LOCATION, StartLocation.HOME)
if (startingAction == null && savedInstanceState != null) {
wasConfigurationUpdated = savedInstanceState.getBoolean(STATE_WAS_CONFIGURATION_UPDATED)
}

View File

@@ -165,7 +165,7 @@ class ChangeNumberViewModel(
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
val context: Application = ApplicationDependencies.getApplication()
val localNumber: String = SignalStore.account().e164!!
val password: String = SignalStore.account().servicePassword!!

View File

@@ -39,6 +39,8 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
}
)
dividerPref()
switchPref(
title = DSLSettingsText.from(R.string.preferences__generate_link_previews),
summary = DSLSettingsText.from(R.string.preferences__retrieve_link_previews_from_websites_for_messages),

View File

@@ -57,7 +57,7 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
}
class Factory(private val repository: ChatsSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ChatsSettingsViewModel(repository)))
}
}

View File

@@ -77,7 +77,7 @@ class DataAndStorageSettingsViewModel(
private val repository: DataAndStorageSettingsRepository
) :
ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(DataAndStorageSettingsViewModel(sharedPreferences, repository)))
}
}

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.DialogInterface
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.AppUtil
import org.signal.core.util.concurrent.SignalExecutors
@@ -28,15 +29,17 @@ import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.math.max
@@ -401,6 +404,20 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
enqueueSubscriptionRedemption()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_enqueue_keep_alive),
onClick = {
enqueueSubscriptionKeepAlive()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_set_error_state),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDonorErrorConfigurationFragment())
}
)
}
dividerPref()
@@ -418,7 +435,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel),
onClick = {
SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0)
RetrieveReleaseChannelJob.enqueue(force = true)
RetrieveRemoteAnnouncementsJob.enqueue(force = true)
}
)
@@ -573,6 +590,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
}
private fun enqueueSubscriptionKeepAlive() {
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
}
private fun clearCdsHistory() {
SignalDatabase.cds.clearAll()
SignalStore.misc().cdsToken = null

View File

@@ -11,6 +11,7 @@ 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.notifications.v2.ConversationId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
@@ -53,7 +54,7 @@ class InternalSettingsRepository(context: Context) {
SignalDatabase.attachments.getAttachmentsForMessage(insertResult.messageId)
.forEach { ApplicationDependencies.getJobManager().add(AttachmentDownloadJob(insertResult.messageId, it.attachmentId, false)) }
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.threadId)
ApplicationDependencies.getMessageNotifier().updateNotification(context, ConversationId.forConversation(insertResult.threadId))
}
}
}

View File

@@ -143,7 +143,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.donations.StripeDeclineCode
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.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
class DonorErrorConfigurationFragment : DSLSettingsFragment() {
private val viewModel: DonorErrorConfigurationViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: DonorErrorConfigurationState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_expired_badge),
selected = state.badges.indexOf(state.selectedBadge),
listItems = state.badges.map { it.name }.toTypedArray(),
onSelected = { viewModel.setSelectedBadge(it) }
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_cancelation_reason),
selected = UnexpectedSubscriptionCancellation.values().indexOf(state.selectedUnexpectedSubscriptionCancellation),
listItems = UnexpectedSubscriptionCancellation.values().map { it.status }.toTypedArray(),
onSelected = { viewModel.setSelectedUnexpectedSubscriptionCancellation(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_charge_failure),
selected = StripeDeclineCode.Code.values().indexOf(state.selectedStripeDeclineCode),
listItems = StripeDeclineCode.Code.values().map { it.code }.toTypedArray(),
onSelected = { viewModel.setStripeDeclineCode(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
primaryButton(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_save_and_finish),
onClick = {
lifecycleDisposable += viewModel.save().subscribe { requireActivity().finish() }
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_clear),
onClick = {
lifecycleDisposable += viewModel.clear().subscribe()
}
)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
data class DonorErrorConfigurationState(
val badges: List<Badge> = emptyList(),
val selectedBadge: Badge? = null,
val selectedUnexpectedSubscriptionCancellation: UnexpectedSubscriptionCancellation? = null,
val selectedStripeDeclineCode: StripeDeclineCode.Code? = null
)

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Locale
class DonorErrorConfigurationViewModel : ViewModel() {
private val store = RxStore(DonorErrorConfigurationState())
private val disposables = CompositeDisposable()
val state: Flowable<DonorErrorConfigurationState> = store.stateFlowable
init {
val giftBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getGiftBadges(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { results -> results.values.map { Badges.fromServiceBadge(it) } }
.subscribeOn(Schedulers.io())
val boostBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getBoostBadge(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { listOf(Badges.fromServiceBadge(it)) }
.subscribeOn(Schedulers.io())
val subscriptionBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getSubscriptionLevels(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { levels -> levels.levels.values.map { Badges.fromServiceBadge(it.badge) } }
.subscribeOn(Schedulers.io())
disposables += Single.zip(giftBadges, boostBadges, subscriptionBadges) { g, b, s ->
g + b + s
}.subscribe { badges ->
store.update { it.copy(badges = badges) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setSelectedBadge(badgeIndex: Int) {
store.update {
it.copy(selectedBadge = if (badgeIndex in it.badges.indices) it.badges[badgeIndex] else null)
}
}
fun setSelectedUnexpectedSubscriptionCancellation(unexpectedSubscriptionCancellationIndex: Int) {
store.update {
it.copy(
selectedUnexpectedSubscriptionCancellation = if (unexpectedSubscriptionCancellationIndex in UnexpectedSubscriptionCancellation.values().indices) {
UnexpectedSubscriptionCancellation.values()[unexpectedSubscriptionCancellationIndex]
} else {
null
}
)
}
}
fun setStripeDeclineCode(stripeDeclineCodeIndex: Int) {
store.update {
it.copy(
selectedStripeDeclineCode = if (stripeDeclineCodeIndex in StripeDeclineCode.Code.values().indices) {
StripeDeclineCode.Code.values()[stripeDeclineCodeIndex]
} else {
null
}
)
}
}
fun save(): Completable {
val snapshot = store.state
val saveState = Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
when {
snapshot.selectedBadge?.isGift() == true -> handleGiftExpiration(snapshot)
snapshot.selectedBadge?.isBoost() == true -> handleBoostExpiration(snapshot)
snapshot.selectedBadge?.isSubscription() == true -> handleSubscriptionExpiration(snapshot)
else -> handleSubscriptionPaymentFailure(snapshot)
}
}
}.subscribeOn(Schedulers.io())
return clear().andThen(saveState)
}
fun clear(): Completable {
return Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
SignalStore.donationsValues().setExpiredBadge(null)
SignalStore.donationsValues().setExpiredGiftBadge(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
}
store.update {
it.copy(
selectedStripeDeclineCode = null,
selectedUnexpectedSubscriptionCancellation = null,
selectedBadge = null
)
}
}
}
private fun handleBoostExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
}
private fun handleGiftExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredGiftBadge(state.selectedBadge)
}
private fun handleSubscriptionExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
handleSubscriptionPaymentFailure(state)
}
private fun handleSubscriptionPaymentFailure(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
state.selectedStripeDeclineCode?.let {
ActiveSubscription.ChargeFailure(
it.code,
"Test Charge Failure",
"Test Network Status",
"Test Network Reason",
"Test"
)
}
)
}
}

View File

@@ -9,6 +9,7 @@ import android.graphics.PorterDuffColorFilter
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.text.TextUtils
import android.view.View
@@ -97,49 +98,61 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__sound),
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchMessageSoundSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.messageNotificationsState.vibrateEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationVibration(!state.messageNotificationsState.vibrateEnabled)
}
)
customPref(
LedColorPreference(
colorValues = ledColorValues,
radioListPreference = RadioListPreference(
title = DSLSettingsText.from(R.string.preferences__led_color),
listItems = ledColorLabels,
selected = ledColorValues.indexOf(state.messageNotificationsState.ledColor),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedColor(ledColorValues[it])
}
)
)
)
if (!NotificationChannels.supported()) {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__pref_led_blink_title),
listItems = ledBlinkLabels,
selected = ledBlinkValues.indexOf(state.messageNotificationsState.ledBlink),
if (Build.VERSION.SDK_INT >= 30) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__customize),
summary = DSLSettingsText.from(R.string.preferences__change_sound_and_vibration),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedBlink(ledBlinkValues[it])
onClick = {
NotificationChannels.openChannelSettings(requireContext(), NotificationChannels.getMessagesChannel(requireContext()), null)
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sound),
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchMessageSoundSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.messageNotificationsState.vibrateEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationVibration(!state.messageNotificationsState.vibrateEnabled)
}
)
customPref(
LedColorPreference(
colorValues = ledColorValues,
radioListPreference = RadioListPreference(
title = DSLSettingsText.from(R.string.preferences__led_color),
listItems = ledColorLabels,
selected = ledColorValues.indexOf(state.messageNotificationsState.ledColor),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedColor(ledColorValues[it])
}
)
)
)
if (!NotificationChannels.supported()) {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__pref_led_blink_title),
listItems = ledBlinkLabels,
selected = ledBlinkValues.indexOf(state.messageNotificationsState.ledBlink),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedBlink(ledBlinkValues[it])
}
)
}
}
switchPref(
@@ -171,24 +184,26 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
}
)
if (NotificationChannels.supported()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchNotificationPriorityIntent()
}
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
listItems = notificationPriorityLabels,
selected = notificationPriorityValues.indexOf(state.messageNotificationsState.priority.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPriority(notificationPriorityValues[it].toInt())
}
)
if (Build.VERSION.SDK_INT < 30) {
if (NotificationChannels.supported()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchNotificationPriorityIntent()
}
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
listItems = notificationPriorityLabels,
selected = notificationPriorityValues.indexOf(state.messageNotificationsState.priority.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPriority(notificationPriorityValues[it].toInt())
}
)
}
}
dividerPref()

View File

@@ -115,7 +115,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
)
class Factory(private val sharedPreferences: SharedPreferences) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(NotificationsSettingsViewModel(sharedPreferences)))
}
}

View File

@@ -78,7 +78,7 @@ class NotificationProfileSelectionViewModel(private val repository: Notification
}
class Factory(private val notificationProfilesRepository: NotificationProfilesRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfileSelectionViewModel(notificationProfilesRepository))!!
}
}

View File

@@ -126,7 +126,7 @@ class PrivacySettingsViewModel(
private val sharedPreferences: SharedPreferences,
private val repository: PrivacySettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(PrivacySettingsViewModel(sharedPreferences, repository)))
}
}

View File

@@ -160,7 +160,7 @@ class AdvancedPrivacySettingsViewModel(
private val sharedPreferences: SharedPreferences,
private val repository: AdvancedPrivacySettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(
modelClass.cast(
AdvancedPrivacySettingsViewModel(

View File

@@ -58,7 +58,7 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
class Factory(context: Context, private val config: Config) : ViewModelProvider.Factory {
val repository = ExpireTimerSettingsRepository(context.applicationContext)
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ExpireTimerSettingsViewModel(config, repository)))
}
}

View File

@@ -60,7 +60,6 @@ import java.util.concurrent.TimeUnit
*/
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val application = activity.application
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
@@ -92,19 +91,16 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
/**
* @param price The amount to charce the local user
* @param paymentData PaymentData from Google Pay that describes the payment method
* @param badgeRecipient Who will be getting the badge
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
* Verifies that the given recipient is a supported target for a gift.
*/
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val verifyRecipient = Completable.fromAction {
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
return Completable.fromAction {
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
val recipient = Recipient.resolved(badgeRecipient)
if (recipient.isSelf) {
Log.d(TAG, "Badge recipient is self, so this is a boost. Skipping verification.", true)
return@fromAction
Log.d(TAG, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
@@ -124,11 +120,19 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
}
}
}.subscribeOn(Schedulers.io())
}
return verifyRecipient.doOnComplete {
Log.d(TAG, "Creating payment intent for $price...", true)
}.andThen(stripeApi.createPaymentIntent(price, badgeLevel))
/**
* @param price The amount to charce the local user
* @param paymentData PaymentData from Google Pay that describes the payment method
* @param badgeRecipient Who will be getting the badge
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
*/
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, badgeLevel)
.onErrorResumeNext {
if (it is DonationError) {
Single.error(it)
@@ -168,6 +172,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return ApplicationDependencies.getDonationsService()
.cancelSubscription(localSubscriber.subscriberId)
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
.ignoreElement()
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
@@ -179,6 +184,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
return ApplicationDependencies
.getDonationsService()
.putSubscription(subscriberId)
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
.doOnComplete {
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
@@ -207,7 +213,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
DonationReceiptRecord.createForGift(price)
}
val donationTypeLabel = donationReceiptRecord.type.code.capitalize(Locale.US)
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
@@ -271,9 +277,8 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
).flatMapCompletable {
if (it.status == 200 || it.status == 204) {
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true)
SignalStore.donationsValues().clearUserManuallyCancelled()
SignalStore.donationsValues().updateLocalStateForLocalSubscribe()
scheduleSyncForAccountRecordChange()
SignalStore.donationsValues().clearLevelOperations()
LevelUpdate.updateProcessingState(false)
Completable.complete()
} else {

View File

@@ -233,7 +233,7 @@ class BoostViewModel(
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}

View File

@@ -84,7 +84,7 @@ class SetCurrencyViewModel(
}
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
}
}

View File

@@ -22,13 +22,8 @@ object DonationErrorDialogs {
val params = DonationErrorParams.create(context, throwable, callback)
if (params.title != null) {
builder.setTitle(params.title)
}
if (params.message != null) {
builder.setMessage(params.message)
}
builder.setTitle(params.title)
.setMessage(params.message)
if (params.positiveAction != null) {
builder.setPositiveButton(params.positiveAction.label) { _, _ -> params.positiveAction.action() }

View File

@@ -71,12 +71,20 @@ class DonationErrorParams<V> private constructor(
}
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__recipient_verification_failed,
message = R.string.DonationsErrors__target_does_not_support_gifting,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
return when (verificationError) {
is DonationError.GiftRecipientVerificationError.FailedToFetchProfile -> DonationErrorParams(
title = R.string.DonationsErrors__could_not_verify_recipient,
message = R.string.DonationsErrors__please_check_your_network_connection,
positiveAction = callback.onOk(context),
negativeAction = null
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__recipient_verification_failed,
message = R.string.DonationsErrors__target_does_not_support_gifting,
positiveAction = callback.onOk(context),
negativeAction = null
)
}
}
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import androidx.annotation.StringRes
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
@StringRes
fun StripeDeclineCode.mapToErrorStringResource(): Int {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> R.string.DeclineCode__your_card_does_not_have_sufficient_funds
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> R.string.DeclineCode__try_completing_the_payment_again
StripeDeclineCode.Code.PROCESSING_ERROR -> R.string.DeclineCode__try_again
StripeDeclineCode.Code.REENTER_TRANSACTION -> R.string.DeclineCode__try_again
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
}
fun StripeDeclineCode.shouldRouteToGooglePay(): Boolean {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> true
StripeDeclineCode.Code.CALL_ISSUER -> true
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> false
StripeDeclineCode.Code.EXPIRED_CARD -> true
StripeDeclineCode.Code.INCORRECT_NUMBER -> true
StripeDeclineCode.Code.INCORRECT_CVC -> true
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> false
StripeDeclineCode.Code.INVALID_CVC -> true
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> true
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> true
StripeDeclineCode.Code.INVALID_NUMBER -> true
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> false
StripeDeclineCode.Code.PROCESSING_ERROR -> false
StripeDeclineCode.Code.REENTER_TRANSACTION -> false
else -> false
}
else -> false
}
}

View File

@@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
/**
* Error states that can occur if we detect that a user's subscription has been cancelled and the manual
* cancellation flag is not set.
*
* This status is taken directly from the ActiveSubscription object, and is set in the Subscription's
* keep-alive and subscription receipt redemption jobs.
*/
enum class UnexpectedSubscriptionCancellation(val status: String) {
PAST_DUE("past_due"),

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadin
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SpanUtil
@@ -67,7 +68,7 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
val expiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge()
if (expiredGiftBadge != null) {
SignalStore.donationsValues().setExpiredBadge(null)
SignalStore.donationsValues().setExpiredGiftBadge(null)
ExpiredGiftSheet.show(childFragmentManager, expiredGiftBadge)
}
@@ -98,31 +99,31 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
if (activeSubscription != null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
if (subscription != null) {
presentSubscriptionSettings(state.hasReceipts, activeSubscription, subscription, state.getRedemptionState())
presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
presentNoSubscriptionSettings(state.hasReceipts)
presentNoSubscriptionSettings()
}
} else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
presentNetworkFailureSettings(state.hasReceipts, state.getRedemptionState())
presentNetworkFailureSettings(state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
}
}
private fun DSLConfiguration.presentNetworkFailureSettings(hasReceipts: Boolean, redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
if (SignalStore.donationsValues().isLikelyASustainer()) {
presentSubscriptionSettingsWithNetworkError(hasReceipts, redemptionState)
presentSubscriptionSettingsWithNetworkError(redemptionState)
} else {
presentNoSubscriptionSettings(hasReceipts)
presentNoSubscriptionSettings()
}
}
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(hasReceipts: Boolean, redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
presentSubscriptionSettingsWithState(hasReceipts, redemptionState) {
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
presentSubscriptionSettingsWithState(redemptionState) {
customPref(
NetworkFailure.Model(
onRetryClick = {
@@ -134,12 +135,11 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
}
private fun DSLConfiguration.presentSubscriptionSettings(
hasReceipts: Boolean,
activeSubscription: ActiveSubscription.Subscription,
subscription: Subscription,
redemptionState: ManageDonationsState.SubscriptionRedemptionState
) {
presentSubscriptionSettingsWithState(hasReceipts, redemptionState) {
presentSubscriptionSettingsWithState(redemptionState) {
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
@@ -160,7 +160,6 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
}
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
hasReceipts: Boolean,
redemptionState: ManageDonationsState.SubscriptionRedemptionState,
subscriptionBlock: DSLConfiguration.() -> Unit
) {
@@ -198,9 +197,7 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
sectionHeaderPref(R.string.ManageDonationsFragment__more)
if (hasReceipts) {
presentDonationReceipts()
}
presentDonationReceipts()
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
@@ -209,7 +206,7 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
)
}
private fun DSLConfiguration.presentNoSubscriptionSettings(hasReceipts: Boolean) {
private fun DSLConfiguration.presentNoSubscriptionSettings() {
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
@@ -227,11 +224,9 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
presentOtherWaysToGive()
if (hasReceipts) {
sectionHeaderPref(R.string.ManageDonationsFragment__receipts)
sectionHeaderPref(R.string.ManageDonationsFragment__receipts)
presentDonationReceipts()
}
presentDonationReceipts()
}
private fun DSLConfiguration.presentOtherWaysToGive() {
@@ -247,7 +242,7 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
}
)
if (FeatureFlags.giftBadges()) {
if (FeatureFlags.giftBadges() && Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge),
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),

View File

@@ -8,7 +8,6 @@ data class ManageDonationsState(
val featuredBadge: Badge? = null,
val transactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList(),
val hasReceipts: Boolean = false,
private val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE
) {

View File

@@ -9,10 +9,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -107,20 +105,12 @@ class ManageDonationsViewModel(
Log.w(TAG, "Error retrieving subscriptions data", it)
}
)
disposables += Single.fromCallable { SignalDatabase.donationReceipts.hasReceipts() }
.subscribeOn(Schedulers.io())
.subscribe { hasReceipts ->
store.update {
it.copy(hasReceipts = hasReceipts)
}
}
}
class Factory(
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
}
}

View File

@@ -36,6 +36,7 @@ object CurrencySelection {
override fun bind(model: Model) {
spinner.text = model.selectedCurrency.currencyCode
spinner.isEnabled = model.isEnabled
itemView.setOnClickListener { model.onClick() }

View File

@@ -63,7 +63,7 @@ class DonationReceiptDetailViewModel(id: Long, private val repository: DonationR
}
class Factory(private val id: Long, private val repository: DonationReceiptDetailRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptDetailViewModel(id, repository)) as T
}
}

View File

@@ -32,6 +32,7 @@ class DonationReceiptListFragment : Fragment(R.layout.donation_receipt_list_frag
0 -> R.string.DonationReceiptListFragment__all
1 -> R.string.DonationReceiptListFragment__recurring
2 -> R.string.DonationReceiptListFragment__one_time
3 -> R.string.DonationReceiptListFragment__gift
else -> error("Unsupported index $position")
}
)

View File

@@ -5,13 +5,14 @@ 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 getItemCount(): Int = 4
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)
3 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.GIFT)
else -> error("Unsupported position $position")
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.receipts
import android.os.Bundle
import android.view.View
import androidx.constraintlayout.widget.Group
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -14,6 +15,7 @@ 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
import org.thoughtcrime.securesms.util.visible
class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_page_fragment) {
@@ -31,6 +33,8 @@ class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_
private val type: DonationReceiptRecord.Type?
get() = requireArguments().getString(ARG_TYPE)?.let { DonationReceiptRecord.Type.fromCode(it) }
private lateinit var emptyStateGroup: Group
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = DonationReceiptListAdapter { model ->
findNavController().safeNavigate(DonationReceiptListFragmentDirections.actionDonationReceiptListFragmentToDonationReceiptDetailFragment(model.record.id))
@@ -41,22 +45,29 @@ class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_
addItemDecoration(StickyHeaderDecoration(adapter, false, true, 0))
}
emptyStateGroup = view.findViewById(R.id.empty_state)
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)
) { state, badges ->
state.isLoaded to state.records.map { DonationReceiptListItem.Model(it, getBadgeForRecord(it, badges)) }
}.observe(viewLifecycleOwner) { (isLoaded, records) ->
if (records.isNotEmpty()) {
emptyStateGroup.visible = false
adapter.submitList(
records +
TextPreference(
title = null,
summary = DSLSettingsText.from(
R.string.DonationReceiptListFragment__if_you_have,
DSLSettingsText.TextAppearanceModifier(R.style.TextAppearance_Signal_Subtitle)
)
)
)
)
)
} else {
emptyStateGroup.visible = isLoaded
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
data class DonationReceiptListPageState(
val records: List<DonationReceiptRecord> = emptyList(),
val isLoaded: Boolean = false
)

View File

@@ -1,24 +1,29 @@
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
import org.thoughtcrime.securesms.util.livedata.Store
class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, repository: DonationReceiptListPageRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val internalState = MutableLiveData<List<DonationReceiptRecord>>()
private val store = Store(DonationReceiptListPageState())
val state: LiveData<List<DonationReceiptRecord>> = internalState
val state: LiveData<DonationReceiptListPageState> = store.stateLiveData
init {
disposables += repository.getRecords(type)
.subscribe { records ->
internalState.postValue(records)
store.update {
it.copy(
records = records,
isLoaded = true
)
}
}
}
@@ -27,7 +32,7 @@ class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, reposi
}
class Factory(private val type: DonationReceiptRecord.Type?, private val repository: DonationReceiptListPageRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptListPageViewModel(type, repository)) as T
}
}

View File

@@ -49,7 +49,7 @@ class DonationReceiptListViewModel(private val repository: DonationReceiptListRe
}
class Factory(private val repository: DonationReceiptListRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptListViewModel(repository)) as T
}
}

View File

@@ -164,12 +164,7 @@ class SubscribeViewModel(
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
if (it) {
donationPaymentRepository.cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
}
} else {
@@ -183,12 +178,7 @@ class SubscribeViewModel(
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
onComplete = {
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().markUserManuallyCancelled()
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
SignalStore.donationsValues().updateLocalStateForManualCancellation()
refreshActiveSubscription()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
donationPaymentRepository.scheduleSyncForAccountRecordChange()
@@ -306,7 +296,7 @@ class SubscribeViewModel(
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}

View File

@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -275,7 +276,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar)
if (Stories.isFeatureEnabled() && avatar.hasStory()) {
val viewStoryIntent = StoryViewerActivity.createIntent(requireContext(), state.recipient.id)
val viewStoryIntent = StoryViewerActivity.createIntent(
requireContext(),
StoryViewerArgs(
recipientId = state.recipient.id,
isInHiddenStoryMode = state.recipient.shouldHideStory()
)
)
StoryDialogs.displayStoryOrProfileImage(
context = requireContext(),
onViewStory = { startActivity(viewStoryIntent) },

View File

@@ -471,7 +471,7 @@ sealed class ConversationSettingsViewModel(
private val repository: ConversationSettingsRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(
modelClass.cast(
when {

View File

@@ -280,7 +280,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
}
class MyViewModelFactory(val recipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return Objects.requireNonNull(modelClass.cast(InternalViewModel(recipientId)))
}
}

View File

@@ -69,7 +69,7 @@ class PermissionsSettingsViewModel(
private val groupId: GroupId,
private val repository: PermissionsSettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository)))
}
}

View File

@@ -38,7 +38,12 @@ object BioTextPreference {
) : BioTextPreferenceModel<RecipientModel>() {
override fun getHeadlineText(context: Context): CharSequence {
val name = recipient.getDisplayNameOrUsername(context)
val name = if (recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
recipient.getDisplayNameOrUsername(context)
}
return if (recipient.showVerified()) {
SpannableStringBuilder(name).apply {
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)

Some files were not shown because too many files have changed in this diff Show More