Compare commits

...

144 Commits

Author SHA1 Message Date
Alex Hart
09afb1be41 Bump version to 6.2.2 2022-11-14 12:48:03 -04:00
Alex Hart
ad2ebfb389 Updated language translations. 2022-11-14 12:45:03 -04:00
Alex Hart
85d7a5c6cc Rotate Credit Cards flag for Beta. 2022-11-14 12:23:10 -04:00
Nicholas Tinsley
4fbbc9d395 Show thumbnail rail when viewing a thread's media. 2022-11-14 11:22:34 -05:00
Alex Hart
e3954ab5e8 Utilize logic from lottie to determine animation scale. 2022-11-14 10:49:55 -04:00
Alex Hart
c1b19390a2 Add 48dp padding to end of gift add message input. 2022-11-14 10:04:02 -04:00
Alex Hart
f7e4e9c855 Fix crash when user does not have a subscription. 2022-11-14 09:59:09 -04:00
Alex Hart
5c6f709faa Do not pre-select my story privacy state. 2022-11-14 09:52:46 -04:00
Alex Hart
47f1d3f594 Add default values to global duration scale resolution. 2022-11-11 13:57:24 -04:00
Greyson Parrelli
2b10f93718 Update hint text for group story replies. 2022-11-11 12:29:36 -05:00
Greyson Parrelli
ccee7577f7 Do not double-insert change number events. 2022-11-11 12:14:13 -05:00
Greyson Parrelli
4e871e2dd8 Bump version to 6.2.1 2022-11-11 10:42:24 -05:00
Greyson Parrelli
455da6649b Updated language translations. 2022-11-11 10:41:52 -05:00
Greyson Parrelli
dc4acd83e8 Fix typo in string. 2022-11-11 10:41:14 -05:00
Alex Hart
0e3a9a3130 Finalize credit card copy. 2022-11-11 10:35:55 -05:00
Greyson Parrelli
ed2edc1ebb Do no double-process the CDSI response. 2022-11-11 10:34:40 -05:00
Greyson Parrelli
6f4de36c6f Bump version to 6.2.0 2022-11-10 17:01:32 -05:00
Greyson Parrelli
e10696b44e Updated language translations. 2022-11-10 17:00:51 -05:00
Alex Hart
6ed1c21a66 Add in new donations strings for credit card support. 2022-11-10 16:58:25 -05:00
Alex Hart
263f7ebac5 Trim zeroes in subscription row items. 2022-11-10 16:58:25 -05:00
Alex Hart
c3063b721d Allow restricted users to update or cancel their subscription. 2022-11-10 16:58:25 -05:00
Cody Henthorne
1dc29fda12 Add in-chat payment messages. 2022-11-10 16:58:25 -05:00
Cody Henthorne
28193c2f61 Allow all notifications to be cancelled when bubbles are disabled. 2022-11-10 16:58:25 -05:00
Alex Hart
9d71c4df81 Refactor a large portion of the payments code to prep it for PayPal support. 2022-11-10 16:58:25 -05:00
Greyson Parrelli
c563ef27da Add UX for handling CDS rate limits. 2022-11-10 16:58:25 -05:00
Cody Henthorne
8eb3a1906e Fully delete remotely deleted stories after sending or on receive. 2022-11-10 10:47:14 -05:00
Cody Henthorne
0309f9ea89 Change destination for remote donation megaphones. 2022-11-10 10:46:57 -05:00
Nicholas
d678341399 Wrap DefaultAudioSink to tolerate init errors. 2022-11-10 10:15:10 -05:00
Nicholas
99f8ba5e0c Enable icons in overflow menu. 2022-11-10 09:37:32 -05:00
Alex Hart
c69b91c4db Add blocked regions from global config for donations payments. 2022-11-10 10:17:13 -04:00
Alex Hart
8f56c1baa5 Add new CC icon for dark mode. 2022-11-09 19:26:48 -05:00
Alex Hart
a0d4026e40 Enable screenshot security for CC fragment. 2022-11-09 19:26:48 -05:00
Cody Henthorne
975b242a08 Fix notification not showing after thread with custom notification is deleted. 2022-11-09 19:26:48 -05:00
Nicholas
f1fafa6516 Gain temporary audio focus during voice memo recording. 2022-11-09 19:26:48 -05:00
Nicholas
fca412b47d Pause videos/GIFs when sharing or forwarding. 2022-11-09 19:26:48 -05:00
Cody Henthorne
18c32a7a80 Only allow active groups to start ringing. 2022-11-09 19:26:48 -05:00
Nicholas
f96c31b38f Always allow remote delete in note to self. 2022-11-09 19:26:48 -05:00
Alex Hart
65a4ef2f70 Update donation strings. 2022-11-09 19:26:48 -05:00
Alex Hart
2b685ea89f Inline the stories flag. 2022-11-09 19:26:48 -05:00
Cody Henthorne
b55954380d Bump various Google Play Services dependencies. 2022-11-09 19:26:48 -05:00
Greyson Parrelli
739a8e9451 Add PNP change number insert events and tests. 2022-11-09 19:26:48 -05:00
Alex Hart
433b5ebc13 Flip case for donation method availability. 2022-11-09 19:26:48 -05:00
Alex Hart
018bb49a03 Cancel boost animations in onStop. 2022-11-09 19:26:48 -05:00
Alex Hart
fc145d7367 Increase height of boost items to align with subscriptions. 2022-11-09 19:26:48 -05:00
Alex Hart
c5f05f322f Rotate Credit Card payments flag. 2022-11-09 19:26:48 -05:00
Greyson Parrelli
b419eb4cd5 Inline internal-only strings. 2022-11-09 19:26:48 -05:00
Alex Hart
9bdf65c4e4 Add androidTest for inserting a direct reply via MessageContentProcessor. 2022-11-09 19:26:48 -05:00
Alex Hart
dbbae7f13f Fix a few flaky instrumentation tests to ensure suite passes. 2022-11-09 19:26:48 -05:00
Alex Hart
513228b366 Update text spacing on donations page. 2022-11-09 19:26:48 -05:00
Greyson Parrelli
a2415261bd Pair usernames flag with the PNP flag. 2022-11-09 19:26:48 -05:00
Alex Hart
8f06381239 Centralize where we make decisions about donations availability. 2022-11-09 19:26:48 -05:00
Alex Hart
f6f1fdb87d Mark unexpected cancellation when silenced so we do not keep hammering the logs. 2022-11-09 19:26:48 -05:00
Alex Hart
b8e16353ab Donations credit card formatting. 2022-11-09 19:26:48 -05:00
Alex Hart
16cbc971a5 Add small amount of unit testing for MessageContentProcessor. 2022-11-09 19:26:47 -05:00
Alex Hart
d1df069669 Add support for Credit Card 3DS during subscriptions. 2022-11-09 19:26:47 -05:00
Greyson Parrelli
844480786e Bump version to 6.1.3 2022-11-09 18:15:01 -05:00
Greyson Parrelli
77aa0424fd Updated language translations. 2022-11-09 18:15:01 -05:00
Alex Hart
4d94d9d968 Utilize areAnimatorsEnabled on API levels that support it. 2022-11-09 18:15:01 -05:00
Greyson Parrelli
89fca76327 Bump version to 6.1.2 2022-11-08 17:38:55 -05:00
Greyson Parrelli
14b9518a48 Updated language translations. 2022-11-08 17:38:55 -05:00
Greyson Parrelli
512ba2b0a8 Show bottom sheet when you tap an avatar in the story viewer. 2022-11-08 17:38:55 -05:00
Cody Henthorne
9851bc300e Fix mentions with share to single group story flow. 2022-11-08 17:38:55 -05:00
Nicholas
a81a4cdb53 Adjust stories view receipts button destination. 2022-11-08 17:38:55 -05:00
Alex Hart
b1d1aee373 Fix infinite animation loop when system animations are disabled. 2022-11-08 17:38:55 -05:00
Cody Henthorne
2cfa31a9b0 Fix crash when typing @ in story add message. 2022-11-07 22:39:54 -05:00
Nicholas
67b6cd164e Manually dismiss keyboard on forwarding messages. 2022-11-07 12:23:26 -05:00
Greyson Parrelli
f241a51fe1 Hopeful fix for crash on API 19. 2022-11-07 11:22:31 -05:00
Nicholas
74c542099a Autoplay all videos. 2022-11-07 09:15:39 -05:00
Nicholas
5d76f13c51 Increase touch target height of seekbar. 2022-11-07 09:15:24 -05:00
Nicholas Tinsley
c6d38600ec Restore wrap_content for album rail. 2022-11-04 17:39:33 -04:00
Cody Henthorne
fc3db538bc Bump version to 6.1.1 2022-11-04 16:38:01 -04:00
Cody Henthorne
acbccc32a6 Updated language translations. 2022-11-04 16:12:29 -04:00
Cody Henthorne
97a502c8c7 Restrict max threads used for large group profile fetching. 2022-11-04 16:08:31 -04:00
Greyson Parrelli
bdba048bc4 Remove possible transaction from identity cache read. 2022-11-04 16:08:30 -04:00
Greyson Parrelli
f7adf2ee5a Fix a typo in a group string. 2022-11-04 16:08:30 -04:00
Alex Hart
dcc9b8ca66 Fix issue with window insets in API30.
Fixes #12525
2022-11-04 16:08:30 -04:00
Nicholas
7ad6d95b27 Fade in media detail view. 2022-11-04 16:08:30 -04:00
Greyson Parrelli
2856697109 Fix string apostrophe. 2022-11-04 16:08:30 -04:00
Nicholas
af89d85696 Fade out video player controls on playback.
2 second delay, cancelable if the video is paused or finished.
2022-11-04 16:08:30 -04:00
Alex Voloshyn
c218e22566 Update MobileCoin enclave measurements for v3.0.0 2022-11-04 16:08:30 -04:00
Varsha
b38ac44d0f Prompt update on MobileCoin enclave failure. 2022-11-03 12:04:51 -04:00
Cody Henthorne
2709f0ee0d Bump version to 6.1.0 2022-11-02 15:51:37 -04:00
Cody Henthorne
c1f84adb2f Updated language translations. 2022-11-02 15:45:33 -04:00
Greyson Parrelli
8c6b7ecc4c Rotate stories feature flags. 2022-11-02 15:40:44 -04:00
Nicholas
5e25e8d0a2 Keep muted chats archived option. 2022-11-02 15:40:44 -04:00
Greyson Parrelli
c674d5b674 Fix bad compose box state if you leave while recording a voice note. 2022-11-02 15:31:52 -04:00
Alex Hart
377841db26 Update some keyboard action handling. 2022-11-02 15:31:52 -04:00
Alex Hart
5da7052da3 Fix blocked refresh and format argument. 2022-11-02 15:31:52 -04:00
Alex Hart
ffeb60fcdd Update tooltip to Material3 spec. 2022-11-02 15:31:52 -04:00
Cody Henthorne
e610ee419f Add internal user remote megaphone conditional. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
d61a35b118 Fix layering issue with action buttons in the contact list. 2022-11-02 15:31:52 -04:00
Alex Hart
ac189865b9 Update pluralization of payments recovery dialog. 2022-11-02 15:31:52 -04:00
Alex Hart
8056aafc9d Pluralize gateway string. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
8ab16164eb Fix PNP issue around thread merging. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
473c8b199e Fix PNP CDS sync bug. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
3692d87531 Add timeout for registering SMS listener during registration. 2022-11-02 15:31:52 -04:00
Cody Henthorne
99a516f8e5 Fix gv1 migration reminder bug.
Fixes #12554
2022-11-02 15:31:52 -04:00
Alex Hart
0ff4175538 Update design for the donation thanks dialog. 2022-11-02 15:31:52 -04:00
Nicholas Tinsley
4e8208c468 Restore LTR ordering for media preview control icons. 2022-11-02 15:31:52 -04:00
Alex Hart
84f0548966 Center story viewport on tall devices. 2022-11-02 15:31:52 -04:00
Cody Henthorne
77beeda62a Add in-chat payment activation requests.
Co-authored-by: Varsha <varsha@mobilecoin.com>
2022-11-02 15:31:52 -04:00
Greyson Parrelli
8c915572fb Fetch own username from the whoami endpoint. 2022-11-02 15:31:52 -04:00
Nicholas
53883ee3d3 Update MediaPreviewV2 design values. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
40ca16bd06 Don't use ordinals when persisting PNP enum. 2022-11-02 15:31:52 -04:00
Jim Gustafson
60dcfb2fe6 Update to RingRTC v2.21.3 2022-11-02 15:31:52 -04:00
Alex Hart
123fb95916 Allow stripe error codes to be upgraded to decline codes. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
1a657a7a19 Put info about data saver in the logs. 2022-11-02 15:31:52 -04:00
elena
f119496da4 Fix back button behaviour in bubbles.
Fixes #12563
2022-11-02 15:31:52 -04:00
Alex Hart
6999d1fbf1 Enforce max gif playback using unreserved count from exo pool. 2022-11-02 15:31:52 -04:00
Alex Hart
c1ff2aeeff Print pool stats whenever we fail to get an ExoPlayer instance. 2022-11-02 15:31:52 -04:00
Alex Hart
4220395649 Tie into dispatcher instead of popBackStack() 2022-11-02 15:31:52 -04:00
Alex Hart
806409b329 Fix crash when entering avatar picker on kitkat. 2022-11-02 15:31:52 -04:00
Greyson Parrelli
3e3296da5b Convert ThreadDatabase to kotlin. 2022-11-02 15:31:52 -04:00
Alex Hart
4bbe01cbc3 Hide stories file size header for text stories. 2022-10-31 13:47:33 -04:00
Cody Henthorne
c357c35303 Add remote megaphone snooze capabilities. 2022-10-31 13:47:33 -04:00
Alex Hart
2ea5c7e3bc Update google pay button to match new styling. 2022-10-31 13:39:33 -04:00
Alex Hart
5b8a729afc Add credit card icon. 2022-10-31 13:39:33 -04:00
Greyson Parrelli
4077dc829a Improve contact pull-to-refresh performance. 2022-10-31 13:39:33 -04:00
Alex Hart
2cfa685ae2 Add basic 3DS support for credit cards. 2022-10-31 13:39:33 -04:00
Cody Henthorne
c686d33a46 Bump version to 6.0.6 2022-10-31 13:16:27 -04:00
Cody Henthorne
e00ed81e7c Fix bad unread mentions database migration. 2022-10-31 12:52:50 -04:00
Cody Henthorne
06c9dbe6ec Bump version to 6.0.5 2022-10-31 11:38:48 -04:00
Cody Henthorne
05377d26de Updated language translations. 2022-10-31 11:27:05 -04:00
Cody Henthorne
b781de2c17 Fix sms megaphone bug. 2022-10-31 10:49:48 -04:00
Nicholas
9f2c7a65ac Fix lifecycle state for media preview.
After a fragment is destroyed, the media remains loaded in the view model, and it is up to the re-created fragment to take that loaded data and make it ready to be viewed.
2022-10-31 10:08:14 -04:00
Nicholas
bae070e60e Remove old MediaPreviewActivity. 2022-10-31 09:23:11 -04:00
Nicholas
34f6d52758 Finish media preview activity if no media present. 2022-10-31 09:07:25 -04:00
Alex Hart
72aac0732c Bump version to 6.0.4 2022-10-28 17:54:15 -03:00
Alex Hart
5da6321c67 Updated language translations. 2022-10-28 17:53:22 -03:00
Alex Hart
4b9e4d739f Ignore warning for androidx transition. 2022-10-28 17:49:50 -03:00
Alex Hart
5d4d6db197 Fix story viewed state retention. 2022-10-28 17:49:50 -03:00
Cody Henthorne
4e3bfadfbe Fix media preview launched from conversation settings crash. 2022-10-28 17:49:50 -03:00
Alex Hart
abb0a25b81 Fix crash with disposable lifecycle. 2022-10-28 17:49:50 -03:00
Alex Hart
e369f56eab Fix various bugs with KitKat preventing stories from launching. 2022-10-28 17:49:50 -03:00
Alex Hart
a066271766 Bump version to 6.0.3 2022-10-27 18:03:36 -03:00
Alex Hart
c6eb241261 Updated language translations. 2022-10-27 18:00:31 -03:00
Greyson Parrelli
906441c90c Revert "Convert ThreadDatabase to kotlin."
This reverts commit 1e88fb428d.
2022-10-27 16:54:06 -04:00
Alex Hart
6f46e9000b Permanent attachment failure.
Co-authored-by: Cody Henthorne <cody@signal.org>
2022-10-27 16:33:33 -04:00
Alex Hart
9ef58516e2 Ensure donation error dialogs are shown from main thread. 2022-10-27 15:50:39 -03:00
Alex Hart
10950756d3 Add proper fallback photo for mystory. 2022-10-27 14:39:58 -03:00
Nicholas
7c4c146189 Add edit button for media preview. 2022-10-27 13:30:54 -04:00
Alex Hart
2f0f4f94a2 Set onboarding duration to 10s per story. 2022-10-27 14:18:10 -03:00
Alex Hart
3600a4818c Update first time navigation screen. 2022-10-27 13:43:52 -03:00
Nicholas
d003dc435a Design and animation updates for Media Preview. 2022-10-27 10:54:14 -04:00
427 changed files with 23363 additions and 16053 deletions

View File

@@ -50,8 +50,8 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 1152
def canonicalVersionName = "6.0.2"
def canonicalVersionCode = 1163
def canonicalVersionName = "6.2.2"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -538,6 +538,7 @@ dependencies {
force = true
}
testImplementation testLibs.hamcrest.hamcrest
testImplementation testLibs.mockk
testImplementation(testFixtures(project(":libsignal-service")))

View File

@@ -0,0 +1,170 @@
package org.thoughtcrime.securesms.conversation
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.Optional
/**
* Helper test for rendering conversation items for preview.
*/
@RunWith(AndroidJUnit4::class)
@Ignore("For testing/previewing manually, no assertions")
class ConversationItemPreviewer {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@Test
fun testShowLongName() {
val other: Recipient = Recipient.resolved(harness.others.first())
SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("Seef", "$$$"))
insertFailedMediaMessage(other = other, attachmentCount = 1)
insertFailedMediaMessage(other = other, attachmentCount = 2)
insertFailedMediaMessage(other = other, body = "Test", attachmentCount = 1)
// insertFailedOutgoingMediaMessage(other = other, body = "Test", attachmentCount = 1)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
// insertMediaMessage(other = other)
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
scenario.onActivity {
}
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
// ThreadUtil.sleep(45000)
}
private fun insertMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
attachment()
}
val message = IncomingMediaMessage(
from = other.id,
body = body,
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
)
SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
ThreadUtil.sleep(1)
}
private fun insertFailedMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
attachment()
}
val message = IncomingMediaMessage(
from = other.id,
body = body,
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
)
val insert = SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
SignalDatabase.attachments.getAttachmentsForMessage(insert.messageId).forEachIndexed { index, attachment ->
// if (index != 1) {
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, insert.messageId)
// } else {
// SignalDatabase.attachments.setTransferState(insert.messageId, attachment, TRANSFER_PROGRESS_STARTED)
// }
}
ThreadUtil.sleep(1)
}
private fun insertFailedOutgoingMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
attachment()
}
val message = OutgoingMediaMessage(
other,
body,
PointerAttachment.forPointers(Optional.of(attachments)),
System.currentTimeMillis(),
-1,
0,
false,
ThreadDatabase.DistributionTypes.DEFAULT,
StoryType.NONE,
null,
false,
null,
emptyList(),
emptyList(),
emptyList(),
emptySet(),
emptySet(),
null
)
val insert = SignalDatabase.mms.insertMessageOutbox(
OutgoingSecureMediaMessage(message),
SignalDatabase.threads.getOrCreateThreadIdFor(other),
false,
null
)
SignalDatabase.attachments.getAttachmentsForMessage(insert).forEachIndexed { index, attachment ->
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, insert)
}
ThreadUtil.sleep(1)
}
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,
Optional.empty(),
Optional.empty(),
1024,
1024,
Optional.empty(),
Optional.of("/not-there.jpg"),
false,
false,
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
)
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Android test to help show SNC dialog quickly with custom data to make sure it displays properly.
*/
@Ignore("For testing/previewing manually, no assertions")
@RunWith(AndroidJUnit4::class)
class SafetyNumberChangeDialogPreviewer {

View File

@@ -291,7 +291,7 @@ class MmsDatabaseTest_stories {
}
@Test
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
// GIVEN
val groupStoryId = MmsHelper.insert(
recipient = myStory,
@@ -312,7 +312,7 @@ class MmsDatabaseTest_stories {
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)
assertTrue(result)
}
@Test

View File

@@ -12,7 +12,6 @@ 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
@@ -26,9 +25,7 @@ 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
@@ -229,9 +226,6 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
/** Merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null)
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
@@ -245,16 +239,12 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(mergedId, existingE164Recipient.id)
changeNumberListener.waitForJobManager()
assertFalse(changeNumberListener.numberChangeWasEnqueued)
// TODO [greyson] Change number
}
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge], but with a number change. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_changedNumber() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
@@ -268,16 +258,12 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(retrievedId, existingE164Recipient.id)
changeNumberListener.waitForJobManager()
assert(changeNumberListener.numberChangeWasEnqueued)
// TODO [greyson] Change number
}
/** No new rules here, just a more complex scenario to show how different rules interact. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A)
@@ -292,8 +278,7 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
assertEquals(ACI_B, existingRecipient2.requireServiceId())
assertFalse(existingRecipient2.hasE164())
changeNumberListener.waitForJobManager()
assert(changeNumberListener.numberChangeWasEnqueued)
// TODO [greyson] Change number
}
/**
@@ -383,9 +368,6 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
/** Verifying a case where a change number job is expected to be enqueued. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_changedNumber() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
@@ -395,8 +377,7 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_B, retrievedRecipient.requireE164())
changeNumberListener.waitForJobManager()
assert(changeNumberListener.numberChangeWasEnqueued)
// TODO [greyson] Change number
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@@ -588,7 +569,7 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
}
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())
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty(), false, false)
}
private fun identityKey(value: Byte): IdentityKey {
@@ -630,24 +611,6 @@ class RecipientDatabaseTest_getAndPossiblyMerge {
val threadId: Long
)
private class ChangeNumberListener {
var numberChangeWasEnqueued = false
private set
fun waitForJobManager() {
ApplicationDependencies.getJobManager().flush()
ThreadUtil.sleep(500)
}
fun enqueue() {
ApplicationDependencies.getJobManager().addListener(
{ job -> job.factoryKey == RecipientChangedNumberJob.KEY },
{ _, _ -> numberChangeWasEnqueued = true }
)
}
}
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"))

View File

@@ -12,16 +12,22 @@ 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.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
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.push.ServiceId
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_processPnpTuple {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var smsDatabase: SmsDatabase
private lateinit var threadDatabase: ThreadDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@@ -29,6 +35,8 @@ class RecipientDatabaseTest_processPnpTuple {
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
smsDatabase = SignalDatabase.sms
threadDatabase = SignalDatabase.threads
ensureDbEmpty()
@@ -180,11 +188,12 @@ class RecipientDatabaseTest_processPnpTuple {
fun onlyPniMatches_noExistingPniSession_changeNumber() {
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
// TODO Verify change number
test {
given(E164_B, PNI_A, null)
given(E164_B, PNI_A, null, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@@ -192,21 +201,23 @@ class RecipientDatabaseTest_processPnpTuple {
fun pniAndAciMatches_changeNumber() {
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
// TODO Verify change number
test {
given(E164_B, PNI_A, ACI_A)
given(E164_B, PNI_A, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun onlyAciMatches_changeNumber() {
// TODO Verify change number
test {
given(E164_B, null, ACI_A)
given(E164_B, null, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@@ -318,29 +329,31 @@ class RecipientDatabaseTest_processPnpTuple {
@Test
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
// TODO Verify change number
test {
given(E164_A, PNI_A, null)
given(E164_B, PNI_B, ACI_A)
given(E164_B, PNI_B, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun merge_e164AndPni_e164Aci_changeNumber() {
// TODO Verify change number
test {
given(E164_A, PNI_A, null)
given(E164_B, null, ACI_A)
given(E164_B, null, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@@ -402,15 +415,23 @@ class RecipientDatabaseTest_processPnpTuple {
private inner class TestCase {
private val generatedIds: LinkedHashSet<RecipientId> = LinkedHashSet()
private var expectCount = 0
private lateinit var outputRecipientId: RecipientId
fun given(e164: String?, pni: PNI?, aci: ACI?) {
generatedIds += insert(e164, pni, aci)
fun given(e164: String?, pni: PNI?, aci: ACI?, createThread: Boolean = false) {
val id = insert(e164, pni, aci)
generatedIds += id
if (createThread) {
// Create a thread and throw a dummy message in it so it doesn't get automatically deleted
threadDatabase.getOrCreateThreadIdFor(Recipient.resolved(id))
smsDatabase.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
}
}
fun process(e164: String?, pni: PNI?, aci: ACI?) {
SignalDatabase.rawDatabase.beginTransaction()
try {
generatedIds += recipientDatabase.processPnpTuple(e164, pni, aci, pniVerified = false).finalId
outputRecipientId = recipientDatabase.processPnpTuple(e164, pni, aci, pniVerified = false).finalId
generatedIds += outputRecipientId
SignalDatabase.rawDatabase.setTransactionSuccessful()
} finally {
SignalDatabase.rawDatabase.endTransaction()
@@ -435,6 +456,10 @@ class RecipientDatabaseTest_processPnpTuple {
fun expectDeleted(id: RecipientId) {
assertNull(get(id))
}
fun expectChangeNumberEvent() {
assertEquals(1, smsDatabase.getChangeNumberMessageCount(outputRecipientId))
}
}
private data class IdRecord(

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.messages
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import org.junit.Rule
import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata
import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestProtos
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
abstract class MessageContentProcessorTest {
@get:Rule
val harness = SignalActivityRule()
protected fun MessageContentProcessor.doProcess(
messageState: MessageState = MessageState.DECRYPTED_OK,
content: SignalServiceContent,
exceptionMetadata: ExceptionMetadata = ExceptionMetadata("sender", 1),
timestamp: Long = 100L,
smsMessageId: Long = -1L
) {
process(messageState, content, exceptionMetadata, timestamp, smsMessageId)
}
protected fun createNormalContentTestSubject(): MessageContentProcessor {
val context = ApplicationProvider.getApplicationContext<Application>()
return MessageContentProcessor.forNormalContent(context)
}
/**
* Creates a valid ServiceContentProto with a data message which can be built via
* `injectDataMessage`. This function is intended to be built on-top of for more
* specific scenario in subclasses.
*
* Example can be seen in __handleStoryMessageTest
*/
protected fun createServiceContentWithDataMessage(
messageSender: Recipient = Recipient.resolved(harness.others.first()),
injectDataMessage: SignalServiceProtos.DataMessage.Builder.() -> Unit
): SignalServiceContentProto {
return TestProtos.build {
serviceContent(
localAddress = address(uuid = harness.self.requireServiceId().uuid()).build(),
metadata = metadata(
address = address(uuid = messageSender.requireServiceId().uuid()).build()
).build()
).apply {
content = content().apply {
dataMessage = dataMessage().apply {
injectDataMessage()
}.build()
}.build()
}.build()
}
}
}

View File

@@ -0,0 +1,181 @@
package org.thoughtcrime.securesms.messages
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.signal.core.util.requireLong
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.MmsHelper
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.TestProtos
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
import kotlin.random.Random
@Suppress("ClassName")
class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorTest() {
@Before
fun setUp() {
SignalDatabase.mms.deleteAllThreads()
}
@After
fun tearDown() {
SignalDatabase.mms.deleteAllThreads()
}
@Test
fun givenContentWithADirectStoryReplyWhenIProcessThenIInsertAReplyInTheCorrectThread() {
val sender = Recipient.resolved(harness.others.first())
val senderThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(sender)
val myStory = Recipient.resolved(SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!!)
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
val expectedSentTime = 200L
val storyMessageId = MmsHelper.insert(
sentTimeMillis = expectedSentTime,
recipient = myStory,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = myStoryThread
)
SignalDatabase.storySends.insert(
messageId = storyMessageId,
recipientIds = listOf(sender.id),
sentTimestamp = expectedSentTime,
allowsReplies = true,
distributionId = DistributionId.MY_STORY
)
val expectedBody = "Hello!"
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
messageSender = sender,
storyAuthor = harness.self,
storySentTimestamp = expectedSentTime
) {
body = expectedBody
}
runTestWithContent(contentProto = storyContent)
val replyId = SignalDatabase.mmsSms.getConversation(senderThreadId, 0, 1).use {
it.moveToFirst()
it.requireLong(MessageDatabase.ID)
}
val replyRecord = SignalDatabase.mms.getMessageRecord(replyId) as MediaMmsMessageRecord
assertEquals(ParentStoryId.DirectReply(storyMessageId).serialize(), replyRecord.parentStoryId!!.serialize())
assertEquals(expectedBody, replyRecord.body)
SignalDatabase.mms.deleteAllThreads()
}
@Test
fun givenContentWithAGroupStoryReplyWhenIProcessThenIInsertAReplyToTheCorrectStory() {
val sender = Recipient.resolved(harness.others[0])
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(
listOf(
DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(sender.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
)
)
.setRevision(0)
.build()
val group = SignalDatabase.groups.create(
groupMasterKey,
decryptedGroupState
)
val groupRecipient = Recipient.externalGroupExact(group)
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val insertResult = MmsHelper.insert(
message = IncomingMediaMessage(
from = sender.id,
sentTimeMillis = 100L,
serverTimeMillis = 101L,
receivedTimeMillis = 102L,
storyType = StoryType.STORY_WITH_REPLIES
),
threadId = threadForGroup
)
val expectedBody = "Hello, World!"
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
messageSender = sender,
storyAuthor = sender,
storySentTimestamp = 100L
) {
groupV2 = TestProtos.build { groupContextV2(masterKeyBytes = groupMasterKey.serialize()).build() }
body = expectedBody
}
runTestWithContent(storyContent)
val replyId = SignalDatabase.mms.getStoryReplies(insertResult.get().messageId).use { cursor ->
assertEquals(1, cursor.count)
cursor.moveToFirst()
cursor.requireLong(MessageDatabase.ID)
}
val replyRecord = SignalDatabase.mms.getMessageRecord(replyId) as MediaMmsMessageRecord
assertEquals(ParentStoryId.GroupReply(insertResult.get().messageId).serialize(), replyRecord.parentStoryId?.serialize())
assertEquals(threadForGroup, replyRecord.threadId)
assertEquals(expectedBody, replyRecord.body)
SignalDatabase.mms.deleteGroupStoryReplies(insertResult.get().messageId)
SignalDatabase.mms.deleteAllThreads()
}
/**
* Creates a ServiceContent proto with a StoryContext, and then
* uses `injectDataMessage` to fill in the data message object.
*/
private fun createServiceContentWithStoryContext(
messageSender: Recipient,
storyAuthor: Recipient,
storySentTimestamp: Long,
injectDataMessage: DataMessage.Builder.() -> Unit
): SignalServiceContentProto {
return createServiceContentWithDataMessage(messageSender) {
storyContext = TestProtos.build {
storyContext(
sentTimestamp = storySentTimestamp,
authorUuid = storyAuthor.requireServiceId().toString()
).build()
}
injectDataMessage()
}
}
private fun runTestWithContent(contentProto: SignalServiceContentProto) {
val content = SignalServiceContent.createFromProto(contentProto)
val testSubject = createNormalContentTestSubject()
testSubject.doProcess(content = content)
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.messages
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.thoughtcrime.securesms.database.SignalDatabase
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
@Suppress("ClassName")
class MessageContentProcessor__handleTextMessageTest : MessageContentProcessorTest() {
@Test
fun givenContentWithATextMessageWhenIProcessThenIInsertTheTextMessage() {
val testSubject: MessageContentProcessor = createNormalContentTestSubject()
val expectedBody = "Hello, World!"
val contentProto: SignalServiceContentProto = createServiceContentWithDataMessage {
body = expectedBody
}
val content = SignalServiceContent.createFromProto(contentProto)
// WHEN
testSubject.doProcess(content = content)
// THEN
val record = SignalDatabase.sms.getMessageRecord(1)
val threadSize = SignalDatabase.mmsSms.getConversationCount(record.threadId)
assertEquals(1, threadSize)
assertTrue(record.isSecure)
assertEquals(expectedBody, record.body)
}
}

View File

@@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.reactivex.rxjava3.schedulers.TestScheduler
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -89,6 +90,7 @@ class UsernameEditFragmentTest {
onView(withContentDescription(R.string.load_more_header__loading)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
}
@Ignore("Flakey espresso test.")
@Test
fun testNicknameUpdateHappyPath() {
val nickname = "Spiderman"

View File

@@ -110,6 +110,7 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
others += recipientId
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.serialize.protos.AddressProto
import org.whispersystems.signalservice.internal.serialize.protos.MetadataProto
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
import java.util.UUID
import kotlin.random.Random
class TestProtos private constructor() {
fun address(
uuid: UUID = UUID.randomUUID()
): AddressProto.Builder {
return AddressProto.newBuilder()
.setUuid(ServiceId.from(uuid).toByteString())
}
fun metadata(
address: AddressProto = address().build(),
): MetadataProto.Builder {
return MetadataProto.newBuilder()
.setAddress(address)
}
fun groupContextV2(
revision: Int = 0,
masterKeyBytes: ByteArray = Random.Default.nextBytes(GroupMasterKey.SIZE)
): GroupContextV2.Builder {
return GroupContextV2.newBuilder()
.setRevision(revision)
.setMasterKey(ByteString.copyFrom(masterKeyBytes))
}
fun storyContext(
sentTimestamp: Long = Random.nextLong(),
authorUuid: String = UUID.randomUUID().toString()
): DataMessage.StoryContext.Builder {
return DataMessage.StoryContext.newBuilder()
.setAuthorUuid(authorUuid)
.setSentTimestamp(sentTimestamp)
}
fun dataMessage(): DataMessage.Builder {
return DataMessage.newBuilder()
}
fun content(): SignalServiceProtos.Content.Builder {
return SignalServiceProtos.Content.newBuilder()
}
fun serviceContent(
localAddress: AddressProto = address().build(),
metadata: MetadataProto = metadata().build()
): SignalServiceContentProto.Builder {
return SignalServiceContentProto.newBuilder()
.setLocalAddress(localAddress)
.setMetadata(metadata)
}
companion object {
fun <T> build(buildFn: TestProtos.() -> T): T {
return TestProtos().buildFn()
}
}
}

View File

@@ -495,10 +495,12 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".MediaPreviewActivity"
<activity android:name=".mediapreview.MediaPreviewV2Activity"
android:label="@string/AndroidManifest__media_preview"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".AvatarPreviewActivity"
@@ -694,9 +696,9 @@
android:screenOrientation="portrait"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediapreview.MediaPreviewV2Activity"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
<activity android:name=".components.settings.app.subscription.donate.DonateToSignalActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<service android:enabled="true" android:name=".exporter.SignalSmsExportService" android:foregroundServiceType="dataSync" />
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>

View File

@@ -15,12 +15,13 @@ public final class AppCapabilities {
private static final boolean ANNOUNCEMENT_GROUPS = true;
private static final boolean SENDER_KEY = true;
private static final boolean CHANGE_NUMBER = true;
private static final boolean STORIES = true;
/**
* @param storageCapable Whether or not the user can use storage service. This is another way of
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, Stories.isFeatureFlagEnabled(), FeatureFlags.giftBadgeReceiveSupport(), FeatureFlags.phoneNumberPrivacy());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, STORIES, FeatureFlags.giftBadgeReceiveSupport(), FeatureFlags.phoneNumberPrivacy());
}
}

View File

@@ -104,6 +104,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onBlockJoinRequest(@NonNull Recipient recipient);
void onRecipientNameClicked(@NonNull RecipientId target);
void onInviteToSignalClicked();
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);

View File

@@ -1,869 +0,0 @@
/*
* Copyright (C) 2014 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ShareCompat;
import androidx.core.util.Pair;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.DepthPageTransformer;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
import org.thoughtcrime.securesms.util.StorageUtil;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Activity for displaying media attachments in-app
*/
public final class MediaPreviewActivity extends PassphraseRequiredActivity
implements LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
MediaRailAdapter.RailItemListener,
MediaPreviewFragment.Events,
VoiceNoteMediaControllerOwner
{
private final static String TAG = Log.tag(MediaPreviewActivity.class);
private ViewPager mediaPager;
private View detailsContainer;
private TextView caption;
private View captionContainer;
private RecyclerView albumRail;
private MediaRailAdapter albumRailAdapter;
private ViewGroup playbackControlsContainer;
private Uri initialMediaUri;
private String initialMediaType;
private long initialMediaSize;
private String initialCaption;
private boolean initialMediaIsVideoGif;
private boolean leftIsRecent;
private MediaPreviewViewModel viewModel;
private ViewPagerListener viewPagerListener;
private int restartItem = -1;
private long threadId = MediaIntentFactory.NOT_IN_A_THREAD;
private boolean cameFromAllMedia;
private boolean showThread;
private MediaDatabase.Sorting sorting;
private FullscreenHelper fullscreenHelper;
private VoiceNoteMediaController voiceNoteMediaController;
private @Nullable Cursor cursor = null;
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
@NonNull MediaRecord mediaRecord,
boolean leftIsRecent)
{
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
Intent intent = new Intent(context, MediaPreviewActivity.class);
intent.putExtra(MediaIntentFactory.THREAD_ID_EXTRA, mediaRecord.getThreadId());
intent.putExtra(MediaIntentFactory.DATE_EXTRA, mediaRecord.getDate());
intent.putExtra(MediaIntentFactory.SIZE_EXTRA, attachment.getSize());
intent.putExtra(MediaIntentFactory.CAPTION_EXTRA, attachment.getCaption());
intent.putExtra(MediaIntentFactory.LEFT_IS_RECENT_EXTRA, leftIsRecent);
intent.putExtra(MediaIntentFactory.IS_VIDEO_GIF, attachment.isVideoGif());
intent.setDataAndType(attachment.getUri(), mediaRecord.getContentType());
return intent;
}
@Override
protected void attachBaseContext(@NonNull Context newBase) {
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
super.attachBaseContext(newBase);
}
@SuppressWarnings("ConstantConditions")
@Override
protected void onCreate(Bundle bundle, boolean ready) {
this.setTheme(R.style.TextSecure_MediaPreview);
setContentView(R.layout.media_preview_activity);
setSupportActionBar(findViewById(R.id.toolbar));
voiceNoteMediaController = new VoiceNoteMediaController(this);
viewModel = new ViewModelProvider(this).get(MediaPreviewViewModel.class);
fullscreenHelper = new FullscreenHelper(this);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
initializeViews();
initializeResources();
initializeObservers();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
@Override
public void onRailItemClicked(int distanceFromActive) {
mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive);
}
@Override
public void onRailItemDeleteClicked(int distanceFromActive) {
throw new UnsupportedOperationException("Callback unsupported.");
}
@SuppressWarnings("ConstantConditions")
private void initializeActionBar() {
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem != null) {
getSupportActionBar().setTitle(getTitleText(mediaItem));
getSupportActionBar().setSubtitle(getSubTitleText(mediaItem));
}
}
private @NonNull String getTitleText(@NonNull MediaItem mediaItem) {
String from;
if (mediaItem.outgoing) from = getString(R.string.MediaPreviewActivity_you);
else if (mediaItem.recipient != null) from = mediaItem.recipient.getDisplayName(this);
else from = "";
if (showThread) {
String titleText = null;
Recipient threadRecipient = mediaItem.threadRecipient;
if (threadRecipient != null) {
if (mediaItem.outgoing) {
if (threadRecipient.isSelf()) {
titleText = getString(R.string.note_to_self);
} else {
titleText = getString(R.string.MediaPreviewActivity_you_to_s, threadRecipient.getDisplayName(this));
}
} else {
if (threadRecipient.isGroup()) {
titleText = getString(R.string.MediaPreviewActivity_s_to_s, from, threadRecipient.getDisplayName(this));
} else {
titleText = getString(R.string.MediaPreviewActivity_s_to_you, from);
}
}
}
return titleText != null ? titleText : from;
} else {
return from;
}
}
private @NonNull String getSubTitleText(@NonNull MediaItem mediaItem) {
if (mediaItem.date > 0) {
return DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), mediaItem.date);
} else {
return getString(R.string.MediaPreviewActivity_draft);
}
}
@Override
public void onResume() {
super.onResume();
initializeMedia();
}
@Override
public void onPause() {
super.onPause();
restartItem = cleanupMedia();
}
@Override
protected void onDestroy() {
if (cursor != null) {
cursor.close();
cursor = null;
}
super.onDestroy();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
initializeResources();
}
private void initializeViews() {
mediaPager = findViewById(R.id.media_pager);
mediaPager.setOffscreenPageLimit(1);
mediaPager.setPageTransformer(true, new DepthPageTransformer());
viewPagerListener = new ViewPagerListener();
mediaPager.addOnPageChangeListener(viewPagerListener);
albumRail = findViewById(R.id.media_preview_album_rail);
albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
albumRail.setItemAnimator(null); // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682
albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
albumRail.setAdapter(albumRailAdapter);
detailsContainer = findViewById(R.id.media_preview_details_container);
caption = findViewById(R.id.media_preview_caption);
captionContainer = findViewById(R.id.media_preview_caption_container);
playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container);
View toolbarLayout = findViewById(R.id.toolbar_layout);
anchorMarginsToBottomInsets(detailsContainer);
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), findViewById(R.id.toolbar));
fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
}
private void initializeResources() {
Intent intent = getIntent();
threadId = intent.getLongExtra(MediaIntentFactory.THREAD_ID_EXTRA, MediaIntentFactory.NOT_IN_A_THREAD);
cameFromAllMedia = intent.getBooleanExtra(MediaIntentFactory.HIDE_ALL_MEDIA_EXTRA, false);
showThread = intent.getBooleanExtra(MediaIntentFactory.SHOW_THREAD_EXTRA, false);
sorting = MediaDatabase.Sorting.values()[intent.getIntExtra(MediaIntentFactory.SORTING_EXTRA, 0)];
initialMediaUri = intent.getData();
initialMediaType = intent.getType();
initialMediaSize = intent.getLongExtra(MediaIntentFactory.SIZE_EXTRA, 0);
initialCaption = intent.getStringExtra(MediaIntentFactory.CAPTION_EXTRA);
leftIsRecent = intent.getBooleanExtra(MediaIntentFactory.LEFT_IS_RECENT_EXTRA, false);
initialMediaIsVideoGif = intent.getBooleanExtra(MediaIntentFactory.IS_VIDEO_GIF, false);
restartItem = -1;
}
private void initializeObservers() {
viewModel.getPreviewData().observe(this, previewData -> {
if (previewData == null || mediaPager == null || mediaPager.getAdapter() == null) {
return;
}
if (!((MediaItemAdapter) mediaPager.getAdapter()).hasFragmentFor(mediaPager.getCurrentItem())) {
Log.d(TAG, "MediaItemAdapter wasn't ready. Posting again...");
viewModel.resubmitPreviewData();
}
View playbackControls = ((MediaItemAdapter) mediaPager.getAdapter()).getPlaybackControls(mediaPager.getCurrentItem());
if (previewData.getAlbumThumbnails().isEmpty() && previewData.getCaption() == null && playbackControls == null) {
detailsContainer.setVisibility(View.GONE);
} else {
detailsContainer.setVisibility(View.VISIBLE);
}
albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE);
albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition());
albumRail.smoothScrollToPosition(previewData.getActivePosition());
captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE);
caption.setText(previewData.getCaption());
if (playbackControls != null) {
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
playbackControls.setLayoutParams(params);
playbackControlsContainer.removeAllViews();
playbackControlsContainer.addView(playbackControls);
} else {
playbackControlsContainer.removeAllViews();
}
});
}
private void initializeMedia() {
if (!isContentTypeSupported(initialMediaType)) {
Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing.");
Toast.makeText(getApplicationContext(), R.string.MediaPreviewActivity_unssuported_media_type, Toast.LENGTH_LONG).show();
finish();
}
Log.i(TAG, "Loading Part URI: " + initialMediaUri);
if (isMediaInDb()) {
LoaderManager.getInstance(this).restartLoader(0, null, this);
} else {
mediaPager.setAdapter(new SingleItemPagerAdapter(getSupportFragmentManager(), initialMediaUri, initialMediaType, initialMediaSize, initialMediaIsVideoGif));
if (initialCaption != null) {
detailsContainer.setVisibility(View.VISIBLE);
captionContainer.setVisibility(View.VISIBLE);
caption.setText(initialCaption);
}
}
}
private int cleanupMedia() {
int restartItem = mediaPager.getCurrentItem();
mediaPager.removeAllViews();
mediaPager.setAdapter(null);
viewModel.setCursor(this, null, leftIsRecent);
return restartItem;
}
private void showOverview() {
startActivity(MediaOverviewActivity.forThread(this, threadId));
}
private void forward() {
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem != null) {
MultiselectForwardFragmentArgs.create(
this,
threadId,
mediaItem.uri,
mediaItem.type,
args -> MultiselectForwardFragment.showBottomSheet(getSupportFragmentManager(), args)
);
}
}
private void share() {
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem != null) {
Uri publicUri = PartAuthority.getAttachmentPublicUri(mediaItem.uri);
String mimeType = Intent.normalizeMimeType(mediaItem.type);
Intent shareIntent = ShareCompat.IntentBuilder.from(this)
.setStream(publicUri)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try {
startActivity(shareIntent);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "No activity existed to share the media.", e);
Toast.makeText(this, R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show();
}
}
}
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint("InlinedApi")
private void saveToDisk() {
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem != null) {
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
if (StorageUtil.canWriteToMediaStore()) {
performSavetoDisk(mediaItem);
return;
}
Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> {
performSavetoDisk(mediaItem);
})
.execute();
});
}
}
private void performSavetoDisk(@NonNull MediaItem mediaItem) {
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
}
@SuppressLint("StaticFieldLeak")
private void deleteMedia() {
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null || mediaItem.attachment == null) {
return;
}
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
builder.setIcon(R.drawable.ic_warning);
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
mediaItem.attachment);
return null;
}
}.execute();
finish();
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.clear();
MenuInflater inflater = this.getMenuInflater();
inflater.inflate(R.menu.media_preview, menu);
super.onCreateOptionsMenu(menu);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
if (!isMediaInDb()) {
menu.findItem(R.id.delete).setVisible(false);
}
super.onPrepareOptionsMenu(menu);
return true;
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == R.id.save) { saveToDisk(); return true; }
if (itemId == R.id.delete) { deleteMedia(); return true; }
if (itemId == android.R.id.home) { finish(); return true; }
return false;
}
private boolean isMediaInDb() {
return threadId != MediaIntentFactory.NOT_IN_A_THREAD;
}
private @Nullable MediaItem getCurrentMediaItem() {
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
if (adapter != null) {
return adapter.getMediaItemFor(mediaPager.getCurrentItem());
} else {
return null;
}
}
public static boolean isContentTypeSupported(final String contentType) {
return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType);
}
@Override
public @NonNull Loader<Pair<Cursor, Integer>> onCreateLoader(int id, Bundle args) {
return new PagingMediaLoader(this, threadId, initialMediaUri, leftIsRecent, sorting);
}
@Override
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
if (data != null) {
if (data.first == cursor) {
return;
}
if (cursor != null) {
cursor.close();
}
cursor = Objects.requireNonNull(data.first);
viewModel.setCursor(this, cursor, leftIsRecent);
int mediaPosition = Objects.requireNonNull(data.second);
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
if (oldAdapter == null) {
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(), this, cursor, mediaPosition, leftIsRecent);
mediaPager.setAdapter(adapter);
adapter.setActive(true);
} else {
oldAdapter.setCursor(cursor, mediaPosition);
oldAdapter.setActive(true);
}
if (oldAdapter == null || restartItem >= 0) {
int item = restartItem >= 0 ? restartItem : mediaPosition;
mediaPager.setCurrentItem(item);
if (item == 0) {
viewPagerListener.onPageSelected(0);
}
}
} else {
onMediaNotAvailable();
}
}
@Override
public void onLoaderReset(@NonNull Loader<Pair<Cursor, Integer>> loader) {
}
@Override
public boolean singleTapOnMedia() {
fullscreenHelper.toggleUiVisibility();
return true;
}
@Override
public void onMediaNotAvailable() {
Toast.makeText(this, R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show();
finish();
}
@Override
public void onMediaReady() {
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return voiceNoteMediaController;
}
private class ViewPagerListener extends ExtendedOnPageChangedListener {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
if (adapter != null) {
MediaItem item = adapter.getMediaItemFor(position);
if (item != null && item.recipient != null) {
item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
}
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
initializeActionBar();
}
}
@Override
public void onPageUnselected(int position) {
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
if (adapter != null) {
MediaItem item = adapter.getMediaItemFor(position);
if (item != null && item.recipient != null) {
item.recipient.live().removeObservers(MediaPreviewActivity.this);
}
adapter.pause(position);
}
}
}
private static class SingleItemPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
private final Uri uri;
private final String mediaType;
private final long size;
private final boolean isVideoGif;
private MediaPreviewFragment mediaPreviewFragment;
SingleItemPagerAdapter(@NonNull FragmentManager fragmentManager,
@NonNull Uri uri,
@NonNull String mediaType,
long size,
boolean isVideoGif)
{
super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
this.uri = uri;
this.mediaType = mediaType;
this.size = size;
this.isVideoGif = isVideoGif;
}
@Override
public int getCount() {
return 1;
}
@NonNull
@Override
public Fragment getItem(int position) {
mediaPreviewFragment = MediaPreviewFragment.newInstance(uri, mediaType, size, true, isVideoGif);
return mediaPreviewFragment;
}
@Override
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
if (mediaPreviewFragment != null) {
mediaPreviewFragment.cleanUp();
mediaPreviewFragment = null;
}
}
@Override
public @Nullable MediaItem getMediaItemFor(int position) {
return new MediaItem(null, null, null, uri, mediaType, -1, true);
}
@Override
public void pause(int position) {
if (mediaPreviewFragment != null) {
mediaPreviewFragment.pause();
}
}
@Override
public @Nullable View getPlaybackControls(int position) {
if (mediaPreviewFragment != null) {
return mediaPreviewFragment.getBottomBarControls();
}
return null;
}
@Override
public boolean hasFragmentFor(int position) {
return mediaPreviewFragment != null;
}
}
private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor, (view, insets) -> {
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
layoutParams.setMargins(insets.getSystemWindowInsetLeft(),
layoutParams.topMargin,
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom());
view.setLayoutParams(layoutParams);
return insets;
});
}
private static class CursorPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
@SuppressLint("UseSparseArrays")
private final Map<Integer, MediaPreviewFragment> mediaFragments = new HashMap<>();
private final Context context;
private final boolean leftIsRecent;
private boolean active;
private Cursor cursor;
private int autoPlayPosition;
CursorPagerAdapter(@NonNull FragmentManager fragmentManager,
@NonNull Context context,
@NonNull Cursor cursor,
int autoPlayPosition,
boolean leftIsRecent)
{
super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
this.context = context.getApplicationContext();
this.cursor = cursor;
this.autoPlayPosition = autoPlayPosition;
this.leftIsRecent = leftIsRecent;
}
public void setActive(boolean active) {
this.active = active;
notifyDataSetChanged();
}
public void setCursor(@NonNull Cursor cursor, int autoPlayPosition) {
this.cursor = cursor;
this.autoPlayPosition = autoPlayPosition;
}
@Override
public int getCount() {
if (!active) return 0;
else return cursor.getCount();
}
@NonNull
@Override
public Fragment getItem(int position) {
boolean autoPlay = autoPlayPosition == position;
int cursorPosition = getCursorPosition(position);
autoPlayPosition = -1;
cursor.moveToPosition(cursorPosition);
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(cursor);
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
MediaPreviewFragment fragment = MediaPreviewFragment.newInstance(attachment, autoPlay);
mediaFragments.put(position, fragment);
return fragment;
}
@Override
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
MediaPreviewFragment removed = mediaFragments.remove(position);
if (removed != null) {
removed.cleanUp();
}
super.destroyItem(container, position, object);
}
public @Nullable MediaItem getMediaItemFor(int position) {
int cursorPosition = getCursorPosition(position);
if (cursor.isClosed() || cursorPosition < 0) {
Log.w(TAG, "Invalid cursor state! Closed: " + cursor.isClosed() + " Position: " + cursorPosition);
return null;
}
cursor.moveToPosition(cursorPosition);
MediaRecord mediaRecord = MediaRecord.from(cursor);
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
RecipientId recipientId = mediaRecord.getRecipientId();
RecipientId threadRecipientId = mediaRecord.getThreadRecipientId();
return new MediaItem(Recipient.live(recipientId).get(),
Recipient.live(threadRecipientId).get(),
attachment,
Objects.requireNonNull(attachment.getUri()),
mediaRecord.getContentType(),
mediaRecord.getDate(),
mediaRecord.isOutgoing());
}
@Override
public void pause(int position) {
MediaPreviewFragment mediaView = mediaFragments.get(position);
if (mediaView != null) mediaView.pause();
}
@Override
public @Nullable View getPlaybackControls(int position) {
MediaPreviewFragment mediaView = mediaFragments.get(position);
if (mediaView != null) return mediaView.getBottomBarControls();
return null;
}
@Override
public boolean hasFragmentFor(int position) {
return mediaFragments.containsKey(position);
}
private int getCursorPosition(int position) {
if (leftIsRecent) return position;
else return cursor.getCount() - 1 - position;
}
}
private static class MediaItem {
private final @Nullable Recipient recipient;
private final @Nullable Recipient threadRecipient;
private final @Nullable DatabaseAttachment attachment;
private final @NonNull Uri uri;
private final @NonNull String type;
private final long date;
private final boolean outgoing;
private MediaItem(@Nullable Recipient recipient,
@Nullable Recipient threadRecipient,
@Nullable DatabaseAttachment attachment,
@NonNull Uri uri,
@NonNull String type,
long date,
boolean outgoing)
{
this.recipient = recipient;
this.threadRecipient = threadRecipient;
this.attachment = attachment;
this.uri = uri;
this.type = type;
this.date = date;
this.outgoing = outgoing;
}
}
interface MediaItemAdapter {
@Nullable MediaItem getMediaItemFor(int position);
void pause(int position);
@Nullable View getPlaybackControls(int position);
boolean hasFragmentFor(int position);
}
}

View File

@@ -342,7 +342,8 @@ public class NewConversationActivity extends ContactSelectionActivity
recipient,
() -> {
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed);
displaySnackbar(R.string.NewConversationActivity__s_has_been_blocked, recipient.getDisplayName(this));
contactsFragment.reset();
}));
})
);
@@ -366,7 +367,7 @@ public class NewConversationActivity extends ContactSelectionActivity
.setPositiveButton(R.string.NewConversationActivity__remove,
(dialog, which) -> {
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed);
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
}));
}
)
@@ -374,7 +375,7 @@ public class NewConversationActivity extends ContactSelectionActivity
.show();
}
private void displaySnackbar(@StringRes int message) {
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_SHORT).show();
private void displaySnackbar(@StringRes int message, Object ... formatArgs) {
Snackbar.make(findViewById(android.R.id.content), getString(message, formatArgs), Snackbar.LENGTH_SHORT).show();
}
}

View File

@@ -1,41 +0,0 @@
package org.thoughtcrime.securesms.animation;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.viewpager.widget.ViewPager;
import androidx.viewpager2.widget.ViewPager2;
/**
* Based on https://developer.android.com/training/animation/screen-slide#depth-page
*/
public final class DepthPageTransformer implements ViewPager.PageTransformer {
private static final float MIN_SCALE = 0.75f;
public void transformPage(@NonNull View view, float position) {
final int pageWidth = view.getWidth();
if (position < -1f) {
view.setAlpha(0f);
} else if (position <= 0f) {
view.setAlpha(1f);
view.setTranslationX(0f);
view.setScaleX(1f);
view.setScaleY(1f);
} else if (position <= 1f) {
view.setAlpha(1f - position);
view.setTranslationX(pageWidth * -position);
final float scaleFactor = MIN_SCALE + (1f - MIN_SCALE) * (1f - Math.abs(position));
view.setScaleX(scaleFactor);
view.setScaleY(scaleFactor);
} else {
view.setAlpha(0f);
}
}
}

View File

@@ -1,41 +0,0 @@
package org.thoughtcrime.securesms.animation
import android.view.View
import androidx.annotation.RequiresApi
import androidx.viewpager2.widget.ViewPager2
private const val MIN_SCALE = 0.75f
/**
* Lifted from https://developer.android.com/develop/ui/views/animations/screen-slide-2#depth-page
*/
@RequiresApi(21)
class DepthPageTransformer2 : ViewPager2.PageTransformer {
override fun transformPage(view: View, position: Float) {
view.apply {
val pageWidth = width
when {
position < -1 -> alpha = 0f
position <= 0 -> {
alpha = 1f
translationX = 0f
translationZ = 0f
scaleX = 1f
scaleY = 1f
}
position <= 1 -> {
alpha = 1 - position
translationX = pageWidth * -position
translationZ = -1f
val scaleFactor = (MIN_SCALE + (1 - MIN_SCALE) * (1 - Math.abs(position)))
scaleX = scaleFactor
scaleY = scaleFactor
}
else -> alpha = 0f
}
}
}
}

View File

@@ -3,15 +3,14 @@ package org.thoughtcrime.securesms.animation.transitions
import android.animation.Animator
import android.animation.ValueAnimator
import android.content.Context
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.view.ViewCompat
import androidx.transition.Transition
import androidx.transition.TransitionValues
import com.google.android.material.floatingactionbutton.FloatingActionButton
@RequiresApi(21)
class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
class FabElevationFadeTransform(context: Context, attrs: AttributeSet) : Transition(context, attrs) {
companion object {
private const val ELEVATION = "CrossfaderTransition.ELEVATION"
@@ -19,23 +18,23 @@ class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transi
override fun captureStartValues(transitionValues: TransitionValues) {
if (transitionValues.view is FloatingActionButton) {
transitionValues.values[ELEVATION] = transitionValues.view.elevation
transitionValues.values[ELEVATION] = ViewCompat.getElevation(transitionValues.view)
}
}
override fun captureEndValues(transitionValues: TransitionValues) {
if (transitionValues.view is FloatingActionButton) {
transitionValues.values[ELEVATION] = transitionValues.view.elevation
transitionValues.values[ELEVATION] = ViewCompat.getElevation(transitionValues.view)
}
}
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (startValues?.view !is FloatingActionButton || endValues?.view !is FloatingActionButton) {
return null
}
val startElevation = startValues.view.elevation
val endElevation = endValues.view.elevation
val startElevation = ViewCompat.getElevation(startValues.view)
val endElevation = ViewCompat.getElevation(endValues.view)
if (startElevation == endElevation) {
return null
}
@@ -46,7 +45,7 @@ class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transi
).apply {
addUpdateListener {
val elevation = it.animatedValue as Float
endValues.view.elevation = elevation
ViewCompat.setElevation(endValues.view, elevation)
}
}
}

View File

@@ -119,7 +119,12 @@ public abstract class Attachment {
public boolean isInProgress() {
return transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE &&
transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED &&
transferState != AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE;
}
public boolean isPermanentlyFailed() {
return transferState == AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE;
}
public long getSize() {

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
@@ -25,13 +26,15 @@ public class AudioRecorder {
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
private final Context context;
private final Context context;
private final AudioRecorderFocusManager audioFocusManager;
private Recorder recorder;
private Uri captureUri;
public AudioRecorder(@NonNull Context context) {
this.context = context;
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording());
}
public void startRecording() {
@@ -52,6 +55,10 @@ public class AudioRecorder {
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
}
recorder.start(fds[1]);
} catch (IOException e) {
Log.w(TAG, e);
@@ -70,6 +77,7 @@ public class AudioRecorder {
return;
}
audioFocusManager.abandonAudioFocus();
recorder.stop();
try {

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.os.Build
import androidx.annotation.RequiresApi
import org.thoughtcrime.securesms.util.ServiceUtil
abstract class AudioRecorderFocusManager(val context: Context) {
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
abstract fun requestAudioFocus(): Int
abstract fun abandonAudioFocus(): Int
companion object {
@JvmStatic
fun create(context: Context, changeListener: OnAudioFocusChangeListener): AudioRecorderFocusManager {
return if (Build.VERSION.SDK_INT >= 26) {
AudioRecorderFocusManager26(context, changeListener)
} else {
AudioRecorderFocusManagerLegacy(context, changeListener)
}
}
}
}
@RequiresApi(26)
private class AudioRecorderFocusManager26(context: Context, changeListener: OnAudioFocusChangeListener) : AudioRecorderFocusManager(context) {
val audioFocusRequest: AudioFocusRequest
init {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(audioAttributes)
.setOnAudioFocusChangeListener(changeListener)
.build()
}
override fun requestAudioFocus(): Int {
return audioManager.requestAudioFocus(audioFocusRequest)
}
override fun abandonAudioFocus(): Int {
return audioManager.abandonAudioFocusRequest(audioFocusRequest)
}
}
private class AudioRecorderFocusManagerLegacy(context: Context, val changeListener: OnAudioFocusChangeListener) : AudioRecorderFocusManager(context) {
override fun requestAudioFocus(): Int {
return audioManager.requestAudioFocus(changeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
}
override fun abandonAudioFocus(): Int {
return audioManager.abandonAudioFocus(changeListener)
}
}

View File

@@ -123,7 +123,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
)
}
clearButton.setOnClickListener { viewModel.clear() }
clearButton.setOnClickListener { viewModel.clearAvatar() }
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
val text = AvatarBundler.extractText(bundle)

View File

@@ -32,7 +32,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
}
}
fun clear() {
fun clearAvatar() {
store.update {
val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)

View File

@@ -11,14 +11,14 @@ import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
/**
* Activity which houses the gift flow.
*/
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()

View File

@@ -1,14 +1,22 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.ProfileUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
import java.util.Currency
import java.util.Locale
@@ -17,6 +25,10 @@ import java.util.Locale
*/
class GiftFlowRepository {
companion object {
private val TAG = Log.tag(GiftFlowRepository::class.java)
}
fun getGiftBadge(): Single<Pair<Long, Badge>> {
return Single
.fromCallable {
@@ -44,4 +56,37 @@ class GiftFlowRepository {
.mapValues { (currency, price) -> FiatMoney(price, currency) }
}
}
/**
* Verifies that the given recipient is a supported target for a gift.
*/
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, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
try {
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
if (!profile.profile.capabilities.isGiftBadges) {
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
} else {
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
}
} catch (e: IOException) {
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -10,11 +10,13 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -30,7 +32,13 @@ class GiftFlowStartFragment : DSLSettingsFragment(
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() },
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
factoryProducer = {
GiftFlowViewModel.Factory(
GiftFlowRepository(),
requireListener<DonationPaymentComponent>().stripeRepository,
OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
)
}
)
private val lifecycleDisposable = LifecycleDisposable()

View File

@@ -5,8 +5,10 @@ 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.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -16,10 +18,13 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.badges.gifts.Gifts
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -34,8 +39,9 @@ import java.util.Currency
* Maintains state as a user works their way through the gift flow.
*/
class GiftFlowViewModel(
val repository: GiftFlowRepository,
val donationPaymentRepository: DonationPaymentRepository
private val giftFlowRepository: GiftFlowRepository,
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModel() {
private var giftToPurchase: Gift? = null
@@ -83,7 +89,7 @@ class GiftFlowViewModel(
}
}
disposables += repository.getGiftPricing().subscribe { giftPrices ->
disposables += giftFlowRepository.getGiftPricing().subscribe { giftPrices ->
store.update {
it.copy(
giftPrices = giftPrices,
@@ -92,7 +98,7 @@ class GiftFlowViewModel(
}
}
disposables += repository.getGiftBadge().subscribeBy(
disposables += giftFlowRepository.getGiftBadge().subscribeBy(
onSuccess = { (giftLevel, giftBadge) ->
store.update {
it.copy(
@@ -135,12 +141,12 @@ class GiftFlowViewModel(
this.giftToPurchase = Gift(giftLevel, giftPrice)
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
disposables += giftFlowRepository.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)
stripeRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
},
onError = this::onPaymentFlowError
)
@@ -156,7 +162,7 @@ class GiftFlowViewModel(
val recipient = store.state.recipient?.id
donationPaymentRepository.onActivityResult(
stripeRepository.onActivityResult(
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
@@ -165,7 +171,14 @@ class GiftFlowViewModel(
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(gift.price, GooglePayPaymentSource(paymentData), recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(gift.price, recipient, gift.level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
.flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts.
.andThen(oneTimeDonationRepository.waitForOneTimeRedemption(gift.price, paymentIntent.intentId, recipient, store.state.additionalMessage?.toString(), gift.level))
}.subscribeBy(
onError = this@GiftFlowViewModel::onPaymentFlowError,
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
@@ -238,13 +251,15 @@ class GiftFlowViewModel(
class Factory(
private val repository: GiftFlowRepository,
private val donationPaymentRepository: DonationPaymentRepository
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
GiftFlowViewModel(
repository,
donationPaymentRepository
stripeRepository,
oneTimeDonationRepository
)
) as T
}

View File

@@ -42,8 +42,13 @@ data class LargeBadge(
override fun bind(model: Model) {
badge.setBadge(model.largeBadge.badge)
name.text = model.largeBadge.badge.name
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
name.text = context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal, model.shortName)
description.text = if (model.largeBadge.badge.isSubscription()) {
context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal_with_a_monthly, model.shortName)
} else {
context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal_with_a_donation, model.shortName)
}
description.setLines(model.maxLines)
description.maxLines = model.maxLines
description.minLines = model.maxLines

View File

@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -21,7 +21,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: BecomeASustainerViewModel by viewModels(
factoryProducer = {
BecomeASustainerViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
BecomeASustainerViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
}
)

View File

@@ -7,10 +7,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.util.livedata.Store
class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
class BecomeASustainerViewModel(subscriptionsRepository: MonthlyDonationRepository) : ViewModel() {
private val store = Store(BecomeASustainerState())
@@ -37,7 +37,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository
private val TAG = Log.tag(BecomeASustainerViewModel::class.java)
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
class Factory(private val subscriptionsRepository: MonthlyDonationRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
}

View File

@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
@@ -31,7 +31,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
private val lifecycleDisposable = LifecycleDisposable()
private val viewModel: BadgesOverviewViewModel by viewModels(
factoryProducer = {
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
}
)

View File

@@ -12,7 +12,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
@@ -23,7 +23,7 @@ private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
class BadgesOverviewViewModel(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
private val subscriptionsRepository: MonthlyDonationRepository
) : ViewModel() {
private val store = Store(BadgesOverviewState())
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
@@ -89,7 +89,7 @@ class BadgesOverviewViewModel(
class Factory(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
private val subscriptionsRepository: MonthlyDonationRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))

View File

@@ -18,12 +18,12 @@ import org.thoughtcrime.securesms.badges.models.LargeBadge
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.databinding.ViewBadgeBottomSheetDialogFragmentBinding
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.visible
@@ -57,7 +57,7 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
}
@Suppress("CascadeIf")
if (PlayServicesUtil.getPlayServicesStatus(requireContext()) != PlayServicesUtil.PlayServicesStatus.SUCCESS) {
if (!InAppDonations.hasAtLeastOnePaymentMethodAvailable()) {
binding.noSupport.visible = true
binding.action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20)
binding.action.setText(R.string.preferences__donate_to_signal)

View File

@@ -20,7 +20,9 @@ import androidx.annotation.StringRes;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class ContactFilterView extends FrameLayout {
@@ -52,6 +54,8 @@ public final class ContactFilterView extends FrameLayout {
this.clearToggle = findViewById(R.id.search_clear);
this.toggleContainer = findViewById(R.id.toggle_container);
EditTextExtensionsKt.setIncognitoKeyboardEnabled(searchText, TextSecurePreferences.isIncognitoKeyboardEnabled(context));
this.keyboardToggle.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {

View File

@@ -475,7 +475,9 @@ public class InputPanel extends LinearLayout
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
fadeInNormalComposeViews();
if (voiceNoteDraftView.getDraft() == null) {
fadeInNormalComposeViews();
}
}
});

View File

@@ -11,7 +11,9 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.addListener
import androidx.core.widget.addTextChangedListener
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import org.thoughtcrime.securesms.util.visible
/**
@@ -39,6 +41,8 @@ class Material3SearchToolbar @JvmOverloads constructor(
close.setOnClickListener { collapse() }
clear.setOnClickListener { input.setText("") }
input.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(context))
input.addTextChangedListener(afterTextChanged = {
clear.visible = !it.isNullOrBlank()
listener?.onSearchTextChange(it?.toString() ?: "")

View File

@@ -20,6 +20,8 @@ import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class SearchToolbar extends LinearLayout {
@@ -57,6 +59,8 @@ public class SearchToolbar extends LinearLayout {
SearchView searchView = (SearchView) searchItem.getActionView();
EditText searchText = searchView.findViewById(R.id.search_src_text);
EditTextExtensionsKt.setIncognitoKeyboardEnabled(searchText, TextSecurePreferences.isIncognitoKeyboardEnabled(getContext()));
searchView.setSubmitButtonEnabled(false);
searchView.setMaxWidth(Integer.MAX_VALUE);

View File

@@ -18,13 +18,17 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.UiThread;
import androidx.appcompat.widget.AppCompatImageView;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.TransitionOptions;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.FitCenter;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import org.signal.core.util.logging.Log;
@@ -34,9 +38,11 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -62,10 +68,12 @@ public class ThumbnailView extends FrameLayout {
private static final int MIN_HEIGHT = 2;
private static final int MAX_HEIGHT = 3;
private ImageView image;
private ImageView blurhash;
private View playOverlay;
private View captionIcon;
private final ImageView image;
private final ImageView blurhash;
private final View playOverlay;
private final View captionIcon;
private final AppCompatImageView errorImage;
private OnClickListener parentClickListener;
private final int[] dimens = new int[2];
@@ -97,6 +105,7 @@ public class ThumbnailView extends FrameLayout {
this.blurhash = findViewById(R.id.thumbnail_blurhash);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
this.errorImage = findViewById(R.id.thumbnail_error);
super.setOnClickListener(new ThumbnailClickDispatcher());
@@ -302,6 +311,34 @@ public class ThumbnailView extends FrameLayout {
boolean showControls, boolean isPreview,
int naturalWidth, int naturalHeight)
{
if (slide.asAttachment().isPermanentlyFailed()) {
this.slide = slide;
transferControls.ifPresent(c -> c.setVisibility(View.GONE));
playOverlay.setVisibility(View.GONE);
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(image);
image.setImageDrawable(null);
int errorImageResource;
if (slide instanceof ImageSlide) {
errorImageResource = R.drawable.ic_photo_slash_outline_24;
} else if (slide instanceof VideoSlide) {
errorImageResource = R.drawable.ic_video_slash_outline_24;
} else {
errorImageResource = R.drawable.ic_error_outline_24;
}
errorImage.setImageResource(errorImageResource);
errorImage.setVisibility(View.VISIBLE);
return new SettableFuture<>(true);
} else {
errorImage.setVisibility(View.GONE);
}
if (showControls) {
getTransferControls().setSlide(slide);
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
@@ -384,13 +421,21 @@ public class ThumbnailView extends FrameLayout {
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height) {
return setImageResource(glideRequests, uri, width, height, true, null);
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height, boolean animate, @Nullable RequestListener<Drawable> listener) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
GlideRequest<Drawable> request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.listener(listener);
if (animate) {
request = request.transition(withCrossFade());
}
if (width > 0 && height > 0) {
request = request.override(width, height);
@@ -532,11 +577,13 @@ public class ThumbnailView extends FrameLayout {
private class ThumbnailClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
if (thumbnailClickListener != null &&
slide != null &&
slide.asAttachment().getUri() != null &&
slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE)
{
boolean validThumbnail = slide != null &&
slide.asAttachment().getUri() != null &&
slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE;
boolean permanentFailure = slide != null && slide.asAttachment().isPermanentlyFailed();
if (thumbnailClickListener != null && (validThumbnail || permanentFailure)) {
thumbnailClickListener.onClick(view, slide);
} else if (parentClickListener != null) {
parentClickListener.onClick(view);

View File

@@ -18,6 +18,7 @@ import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -58,7 +59,7 @@ public class TooltipPopup extends PopupWindow {
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
this.anchor = anchor;
this.anchor = anchor;
this.position = getRtlPosition(anchor.getContext(), rawPosition);
this.startMargin = startMargin;
@@ -83,10 +84,10 @@ public class TooltipPopup extends PopupWindow {
if (backgroundTint == 0) {
bubble.getBackground().setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.SRC_IN);
} else {
bubble.getBackground().setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(backgroundTint, PorterDuff.Mode.SRC_IN);
}
if (iconGlideModel != null) {
@@ -148,8 +149,10 @@ public class TooltipPopup extends PopupWindow {
switch (position) {
case POSITION_ABOVE:
xoffset += startMargin;
xoffset -= DimensionUnit.DP.toPixels(20);
case POSITION_BELOW:
xoffset += startMargin;
xoffset -= DimensionUnit.DP.toPixels(20);
break;
case POSITION_LEFT:
xoffset += startMargin;

View File

@@ -183,15 +183,20 @@ public final class TransferControlView extends FrameLayout {
}
private int getTransferState(@NonNull List<Slide> slides) {
int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE;
int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE;
boolean allFailed = true;
for (Slide slide : slides) {
if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
transferState = slide.getTransferState();
} else {
transferState = Math.max(transferState, slide.getTransferState());
if (slide.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false;
if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
transferState = slide.getTransferState();
} else {
transferState = Math.max(transferState, slide.getTransferState());
}
}
}
return transferState;
return allFailed ? AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE : transferState;
}
private String getDownloadText(@NonNull List<Slide> slides) {

View File

@@ -13,6 +13,8 @@ import androidx.appcompat.widget.AppCompatEditText;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.HashSet;
import java.util.Set;
@@ -48,6 +50,10 @@ public class EmojiEditText extends AppCompatEditText {
listener.onFocusChange(v, hasFocus);
}
});
if (!isInEditMode()) {
EditTextExtensionsKt.setIncognitoKeyboardEnabled(this, TextSecurePreferences.isIncognitoKeyboardEnabled(context));
}
}
public void insertEmoji(String emoji) {

View File

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

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.days
/**
* Reminder shown when CDS is in a permanent error state, preventing us from doing a sync.
*/
class CdsPermanentErrorReminder(context: Context) : Reminder(null, context.getString(R.string.reminder_cds_permanent_error_body)) {
init {
addAction(
Action(
context.getString(R.string.reminder_cds_permanent_error_learn_more),
R.id.reminder_action_cds_permanent_error_learn_more
)
)
}
override fun isDismissable(): Boolean {
return false
}
override fun getImportance(): Importance {
return Importance.ERROR
}
companion object {
/**
* Even if we're not truly "permanently blocked", if the time until we're unblocked is long enough, we'd rather show the permanent error message than
* telling the user to wait for 3 months or something.
*/
val PERMANENT_TIME_CUTOFF = 30.days.inWholeMilliseconds
@JvmStatic
fun isEligible(): Boolean {
val timeUntilUnblock = SignalStore.misc().cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc().isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
}
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Reminder shown when CDS is rate-limited, preventing us from temporarily doing a refresh.
*/
class CdsTemporyErrorReminder(context: Context) : Reminder(null, context.getString(R.string.reminder_cds_warning_body)) {
init {
addAction(
Action(
context.getString(R.string.reminder_cds_warning_learn_more),
R.id.reminder_action_cds_temporary_error_learn_more
)
)
}
override fun isDismissable(): Boolean {
return false
}
override fun getImportance(): Importance {
return Importance.ERROR
}
companion object {
@JvmStatic
fun isEligible(): Boolean {
val timeUntilUnblock = SignalStore.misc().cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc().isCdsBlocked && timeUntilUnblock < CdsPermanentErrorReminder.PERMANENT_TIME_CUTOFF
}
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.PlayStoreUtil
/**
* Banner to update app to the latest version because of enclave failure
*/
class EnclaveFailureReminder(context: Context) : Reminder(
null,
context.getString(R.string.EnclaveFailureReminder_update_signal)
) {
init {
addAction(Action(context.getString(R.string.ExpiredBuildReminder_update_now), R.id.reminder_action_update_now))
okListener = View.OnClickListener { PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context) }
}
override fun isDismissable(): Boolean = false
override fun getImportance(): Importance {
return Importance.TERMINAL
}
}

View File

@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
@@ -34,7 +34,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
private var wasConfigurationUpdated = false
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {

View File

@@ -18,9 +18,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories.isFeatureFlagEnabled
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -71,7 +69,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
if (state.allowUserToGoToDonationManagementScreen) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
@@ -107,15 +105,13 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (isFeatureFlagEnabled()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__stories),
icon = DSLSettingsIcon.from(R.drawable.ic_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__stories),
icon = DSLSettingsIcon.from(R.drawable.ic_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__notifications),

View File

@@ -5,5 +5,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasExpiredGiftBadge: Boolean
val hasExpiredGiftBadge: Boolean,
val allowUserToGoToDonationManagementScreen: Boolean
)

View File

@@ -2,23 +2,52 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
class AppSettingsViewModel : ViewModel() {
class AppSettingsViewModel(
monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
) : ViewModel() {
private val store = Store(AppSettingsState(Recipient.self(), 0, SignalStore.donationsValues().getExpiredGiftBadge() != null))
private val store = Store(
AppSettingsState(
Recipient.self(),
0,
SignalStore.donationsValues().getExpiredGiftBadge() != null,
SignalStore.donationsValues().isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable()
)
)
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
private val disposables = CompositeDisposable()
val state: LiveData<AppSettingsState> = store.stateLiveData
init {
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.map { it.unreadCount }.orElse(0)) }
store.update(selfLiveData) { self, state -> state.copy(self = self) }
disposables += monthlyDonationRepository.getActiveSubscription().subscribeBy(
onSuccess = { activeSubscription ->
store.update { state ->
state.copy(allowUserToGoToDonationManagementScreen = activeSubscription.isActive || InAppDonations.hasAtLeastOnePaymentMethodAvailable())
}
},
onError = {}
)
}
override fun onCleared() {
disposables.clear()
}
fun refreshExpiredGiftBadge() {

View File

@@ -44,6 +44,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevic
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
@@ -90,6 +91,7 @@ class ChangeNumberRepository(
emitter.onComplete()
}
}.subscribeOn(Schedulers.single())
.timeout(15, TimeUnit.SECONDS)
}
fun changeNumber(code: String, newE164: String, pniUpdateMode: Boolean = false): Single<ServiceResponse<VerifyAccountResponse>> {

View File

@@ -50,6 +50,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun requestCode() {
lifecycleDisposable += viewModel
.ensureDecryptionsDrained()
.onErrorComplete()
.andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER))
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processor ->

View File

@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -103,6 +104,17 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
}
)
if (FeatureFlags.keepMutedChatsArchived() || FeatureFlags.internalUser()) {
switchPref(
title = DSLSettingsText.from(R.string.preferences__pref_keep_muted_chats_archived),
summary = DSLSettingsText.from(R.string.preferences__muted_chats_that_are_archived_will_remain_archived),
isChecked = state.keepMutedChatsArchived,
onClick = {
viewModel.setKeepMutedChatsArchived(!state.keepMutedChatsArchived)
}
)
}
dividerPref()
sectionHeaderPref(R.string.ChatsSettingsFragment__keyboard)

View File

@@ -39,4 +39,11 @@ class ChatsSettingsRepository {
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun syncKeepMutedChatsArchivedState() {
SignalExecutors.BOUNDED.execute {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
}

View File

@@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsExportSta
data class ChatsSettingsState(
val generateLinkPreviews: Boolean,
val useAddressBook: Boolean,
val keepMutedChatsArchived: Boolean,
val useSystemEmoji: Boolean,
val enterKeySends: Boolean,
val chatBackupsEnabled: Boolean,

View File

@@ -25,6 +25,7 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
ChatsSettingsState(
generateLinkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()),
@@ -57,6 +58,12 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
repository.syncPreferSystemContactPhotos()
}
fun setKeepMutedChatsArchived(enabled: Boolean) {
store.update { it.copy(keepMutedChatsArchived = enabled) }
SignalStore.settings().setKeepMutedChatsArchived(enabled)
repository.syncKeepMutedChatsArchivedState()
}
fun setUseSystemEmoji(enabled: Boolean) {
store.update { it.copy(useSystemEmoji = enabled) }
SignalStore.settings().isPreferSystemEmoji = enabled

View File

@@ -88,35 +88,35 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
private fun getConfiguration(state: InternalSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.preferences__internal_account)
sectionHeaderPref(DSLSettingsText.from("Account"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_refresh_attributes),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_attributes_description),
title = DSLSettingsText.from("Refresh attributes"),
summary = DSLSettingsText.from("Forces a write of capabilities on to the server followed by a read."),
onClick = {
refreshAttributes()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_refresh_profile),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_profile_description),
title = DSLSettingsText.from("Refresh profile"),
summary = DSLSettingsText.from("Forces a refresh of your own profile."),
onClick = {
refreshProfile()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_rotate_profile_key),
summary = DSLSettingsText.from(R.string.preferences__internal_rotate_profile_key_description),
title = DSLSettingsText.from("Rotate profile key"),
summary = DSLSettingsText.from("Creates a new versioned profile, and triggers an update of any GV2 group you belong to."),
onClick = {
rotateProfileKey()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config_description),
title = DSLSettingsText.from("Refresh remote config"),
summary = DSLSettingsText.from("Forces a refresh of the remote config locally instead of waiting for the elapsed time."),
onClick = {
refreshRemoteValues()
}
@@ -124,11 +124,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_misc)
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_user_details),
summary = DSLSettingsText.from(R.string.preferences__internal_user_details_description),
title = DSLSettingsText.from("'Internal Details' button"),
summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."),
isChecked = state.seeMoreUserDetails,
onClick = {
viewModel.setSeeMoreUserDetails(!state.seeMoreUserDetails)
@@ -136,8 +136,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_shake_to_report),
summary = DSLSettingsText.from(R.string.preferences__internal_shake_to_report_description),
title = DSLSettingsText.from("Shake to Report"),
summary = DSLSettingsText.from("Shake your phone to easily submit and share a debug log."),
isChecked = state.shakeToReport,
onClick = {
viewModel.setShakeToReport(!state.shakeToReport)
@@ -145,7 +145,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_keep_longer_logs),
title = DSLSettingsText.from("Clear keep longer logs"),
onClick = {
clearKeepLongerLogs()
}
@@ -153,11 +153,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_payments)
sectionHeaderPref(DSLSettingsText.from("Payments"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_payment_copy_data),
summary = DSLSettingsText.from(R.string.preferences__internal_payment_copy_data_description),
title = DSLSettingsText.from("Copy payments data"),
summary = DSLSettingsText.from("Copy all payment records to clipboard."),
onClick = {
copyPaymentsDataToClipboard()
}
@@ -165,11 +165,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_storage_service)
sectionHeaderPref(DSLSettingsText.from("Storage Service"))
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service),
summary = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service_description),
title = DSLSettingsText.from("Disable syncing"),
summary = DSLSettingsText.from("Prevent syncing any data to/from storage service."),
isChecked = state.disableStorageService,
onClick = {
viewModel.setDisableStorageService(!state.disableStorageService)
@@ -177,16 +177,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_sync_now),
summary = DSLSettingsText.from(R.string.preferences__internal_sync_now_description),
title = DSLSettingsText.from("Sync now"),
summary = DSLSettingsText.from("Enqueue a normal storage service sync."),
onClick = {
enqueueStorageServiceSync()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync),
summary = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync_description),
title = DSLSettingsText.from("Overwrite remote data"),
summary = DSLSettingsText.from("Forces remote storage to match the local device state."),
onClick = {
enqueueStorageServiceForcePush()
}
@@ -194,11 +194,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_preferences_groups_v2)
sectionHeaderPref(DSLSettingsText.from("Groups V2"))
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_gv2_invites),
summary = DSLSettingsText.from(R.string.preferences__internal_force_gv2_invites_description),
title = DSLSettingsText.from("Force invites"),
summary = DSLSettingsText.from("Members will not be added directly to a GV2 even if they could be."),
isChecked = state.gv2forceInvites,
onClick = {
viewModel.setGv2ForceInvites(!state.gv2forceInvites)
@@ -206,8 +206,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_ignore_gv2_server_changes),
summary = DSLSettingsText.from(R.string.preferences__internal_ignore_gv2_server_changes_description),
title = DSLSettingsText.from("Ignore server changes"),
summary = DSLSettingsText.from("Changes in server's response will be ignored, causing passive voice update messages if P2P is also ignored."),
isChecked = state.gv2ignoreServerChanges,
onClick = {
viewModel.setGv2IgnoreServerChanges(!state.gv2ignoreServerChanges)
@@ -215,8 +215,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_ignore_gv2_p2p_changes),
summary = DSLSettingsText.from(R.string.preferences__internal_ignore_gv2_server_changes_description),
title = DSLSettingsText.from("Ignore P2P changes"),
summary = DSLSettingsText.from("Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice."),
isChecked = state.gv2ignoreP2PChanges,
onClick = {
viewModel.setGv2IgnoreP2PChanges(!state.gv2ignoreP2PChanges)
@@ -225,7 +225,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_network)
sectionHeaderPref(DSLSettingsText.from("Network"))
switchPref(
title = DSLSettingsText.from("Force websocket mode"),
@@ -247,8 +247,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_allow_censorship_toggle),
summary = DSLSettingsText.from(R.string.preferences__internal_allow_censorship_toggle_description),
title = DSLSettingsText.from("Allow censorship circumvention toggle"),
summary = DSLSettingsText.from("Allow changing the censorship circumvention toggle regardless of network connectivity."),
isChecked = state.allowCensorshipSetting,
onClick = {
viewModel.setAllowCensorshipSetting(!state.allowCensorshipSetting)
@@ -257,11 +257,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_conversations_and_shortcuts)
sectionHeaderPref(DSLSettingsText.from("Conversations and Shortcuts"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_delete_all_dynamic_shortcuts),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_delete_all_dynamic_shortcuts),
title = DSLSettingsText.from("Delete all dynamic shortcuts"),
summary = DSLSettingsText.from("Click to delete all dynamic shortcuts"),
onClick = {
deleteAllDynamicShortcuts()
}
@@ -269,20 +269,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_emoji)
sectionHeaderPref(DSLSettingsText.from("Emoji"))
val emojiSummary = if (state.emojiVersion == null) {
getString(R.string.preferences__internal_use_built_in_emoji_set)
"Use built-in emoji set"
} else {
getString(
R.string.preferences__internal_current_version_d_at_density_s,
state.emojiVersion.version,
state.emojiVersion.density
)
"Current version: ${state.emojiVersion.version} at density ${state.emojiVersion.density}"
}
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_use_built_in_emoji_set),
title = DSLSettingsText.from("Use built-in emoji set"),
summary = DSLSettingsText.from(emojiSummary),
isChecked = state.useBuiltInEmojiSet,
onClick = {
@@ -291,16 +287,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download),
summary = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download_description),
title = DSLSettingsText.from("Force emoji download"),
summary = DSLSettingsText.from("Download the latest emoji set if it\\'s newer than what we have."),
onClick = {
ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(true))
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_search_index_download),
summary = DSLSettingsText.from(R.string.preferences__internal_force_search_index_download_description),
title = DSLSettingsText.from("Force search index download"),
summary = DSLSettingsText.from("Download the latest emoji search index if it\\'s newer than what we have."),
onClick = {
EmojiSearchIndexDownloadJob.scheduleImmediately()
}
@@ -308,27 +304,27 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_sender_key)
sectionHeaderPref(DSLSettingsText.from("Sender Key"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_all_state),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_delete_all_sender_key_state),
title = DSLSettingsText.from("Clear all state"),
summary = DSLSettingsText.from("Click to delete all sender key state"),
onClick = {
clearAllSenderKeyState()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_shared_state),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_delete_all_sharing_state),
title = DSLSettingsText.from("Clear shared state"),
summary = DSLSettingsText.from("Click to delete all sharing state"),
onClick = {
clearAllSenderKeySharedState()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_remove_two_person_minimum),
summary = DSLSettingsText.from(R.string.preferences__internal_remove_the_requirement_that_you_need),
title = DSLSettingsText.from("Remove 2 person minimum"),
summary = DSLSettingsText.from("Remove the requirement that you need at least 2 recipients to use sender key."),
isChecked = state.removeSenderKeyMinimium,
onClick = {
viewModel.setRemoveSenderKeyMinimum(!state.removeSenderKeyMinimium)
@@ -336,8 +332,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_delay_resends),
summary = DSLSettingsText.from(R.string.preferences__internal_delay_resending_messages_in_response_to_retry_receipts),
title = DSLSettingsText.from("Delay resends"),
summary = DSLSettingsText.from("Delay resending messages in response to retry receipts by 10 seconds."),
isChecked = state.delayResends,
onClick = {
viewModel.setDelayResends(!state.delayResends)
@@ -346,11 +342,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_local_metrics)
sectionHeaderPref(DSLSettingsText.from("Local Metrics"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_local_metrics),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_clear_all_local_metrics_state),
title = DSLSettingsText.from("Clear local metrics"),
summary = DSLSettingsText.from("Click to clear all local metrics state."),
onClick = {
clearAllLocalMetricsState()
}
@@ -358,10 +354,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_calling_server)
sectionHeaderPref(DSLSettingsText.from("Group call server"))
radioPref(
title = DSLSettingsText.from(R.string.preferences__internal_calling_server_default),
title = DSLSettingsText.from("Default"),
summary = DSLSettingsText.from(BuildConfig.SIGNAL_SFU_URL),
isChecked = state.callingServer == BuildConfig.SIGNAL_SFU_URL,
onClick = {
@@ -372,7 +368,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
BuildConfig.SIGNAL_SFU_INTERNAL_NAMES.zip(BuildConfig.SIGNAL_SFU_INTERNAL_URLS)
.forEach { (name, server) ->
radioPref(
title = DSLSettingsText.from(requireContext().getString(R.string.preferences__internal_calling_server_s, name)),
title = DSLSettingsText.from("$name server"),
summary = DSLSettingsText.from(server),
isChecked = state.callingServer == server,
onClick = {
@@ -381,10 +377,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
}
sectionHeaderPref(R.string.preferences__internal_calling)
sectionHeaderPref(DSLSettingsText.from("Calling options"))
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_calling_audio_processing_method),
title = DSLSettingsText.from("Audio processing method"),
listItems = CallManager.AudioProcessingMethod.values().map { it.name }.toTypedArray(),
selected = CallManager.AudioProcessingMethod.values().indexOf(state.callingAudioProcessingMethod),
onSelected = {
@@ -393,7 +389,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_calling_bandwidth_mode),
title = DSLSettingsText.from("Bandwidth mode"),
listItems = CallManager.BandwidthMode.values().map { it.name }.toTypedArray(),
selected = CallManager.BandwidthMode.values().indexOf(state.callingBandwidthMode),
onSelected = {
@@ -402,7 +398,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_calling_disable_telecom),
title = DSLSettingsText.from("Disable Telecom integration"),
isChecked = state.callingDisableTelecom,
onClick = {
viewModel.setInternalCallingDisableTelecom(!state.callingDisableTelecom)
@@ -412,24 +408,24 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
if (SignalStore.donationsValues().getSubscriber() != null) {
dividerPref()
sectionHeaderPref(R.string.preferences__internal_badges)
sectionHeaderPref(DSLSettingsText.from("Badges"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_enqueue_redemption),
title = DSLSettingsText.from("Enqueue redemption."),
onClick = {
enqueueSubscriptionRedemption()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_enqueue_keep_alive),
title = DSLSettingsText.from("Enqueue keep-alive."),
onClick = {
enqueueSubscriptionKeepAlive()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_set_error_state),
title = DSLSettingsText.from("Set error state."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDonorErrorConfigurationFragment())
}
@@ -438,17 +434,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_release_channel)
sectionHeaderPref(DSLSettingsText.from("Release channel"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_release_channel_set_last_version),
title = DSLSettingsText.from("Set last version seen back 10 versions"),
onClick = {
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_reset_donation_megaphone),
title = DSLSettingsText.from("Reset donation megaphone"),
onClick = {
SignalDatabase.remoteMegaphones.debugRemoveAll()
MegaphoneDatabase.getInstance(ApplicationDependencies.getApplication()).let {
@@ -461,7 +457,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel),
title = DSLSettingsText.from("Fetch release channel"),
onClick = {
SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0)
RetrieveRemoteAnnouncementsJob.enqueue(force = true)
@@ -469,7 +465,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_add_sample_note),
title = DSLSettingsText.from("Add sample note"),
onClick = {
viewModel.addSampleReleaseNote()
}
@@ -477,27 +473,27 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_cds)
sectionHeaderPref(DSLSettingsText.from("CDS"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_history),
summary = DSLSettingsText.from(R.string.preferences__internal_clear_history_description),
title = DSLSettingsText.from("Clear history"),
summary = DSLSettingsText.from("Clears all CDS history, meaning the next sync will consider all numbers to be new."),
onClick = {
clearCdsHistory()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_all_service_ids),
summary = DSLSettingsText.from(R.string.preferences__internal_clear_all_service_ids_description),
title = DSLSettingsText.from("Clear all service IDs"),
summary = DSLSettingsText.from("Clears all known service IDs (except your own) for people that have phone numbers. Do not use on your personal device!"),
onClick = {
clearAllServiceIds()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_all_profile_keys),
summary = DSLSettingsText.from(R.string.preferences__internal_clear_all_profile_keys_description),
title = DSLSettingsText.from("Clear all profile keys"),
summary = DSLSettingsText.from("Clears all known profile keys (except your own). Do not use on your personal device!"),
onClick = {
clearAllProfileKeys()
}
@@ -505,11 +501,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.ConversationListTabs__stories)
sectionHeaderPref(DSLSettingsText.from("Stories"))
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_onboarding_state),
summary = DSLSettingsText.from(R.string.preferences__internal_clears_onboarding_flag_and_triggers_download_of_onboarding_stories),
title = DSLSettingsText.from("Clear onboarding state"),
summary = DSLSettingsText.from("Clears onboarding flag and triggers download of onboarding stories."),
isEnabled = state.canClearOnboardingState,
onClick = {
viewModel.onClearOnboardingState()
@@ -517,7 +513,23 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_stories_dialog_launcher),
title = DSLSettingsText.from("Clear choose initial my story privacy state"),
isEnabled = true,
onClick = {
SignalStore.storyValues().userHasBeenNotifiedAboutStories = false
}
)
clickPref(
title = DSLSettingsText.from("Clear first time navigation state"),
isEnabled = true,
onClick = {
SignalStore.storyValues().userHasSeenFirstNavView = false
}
)
clickPref(
title = DSLSettingsText.from("Stories dialog launcher"),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToStoryDialogsLauncherFragment())
}

View File

@@ -9,7 +9,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
class StoryDialogLauncherFragment : DSLSettingsFragment(titleId = R.string.preferences__internal_stories_dialog_launcher) {
class InternalStoryDialogLauncherFragment : DSLSettingsFragment(titleId = R.string.preferences__internal_stories_dialog_launcher) {
override fun bindAdapter(adapter: MappingAdapter) {
adapter.submitList(getConfiguration().toMappingModelList())
}
@@ -17,25 +17,25 @@ class StoryDialogLauncherFragment : DSLSettingsFragment(titleId = R.string.prefe
private fun getConfiguration(): DSLConfiguration {
return configure {
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_remove_group_story),
title = DSLSettingsText.from("Remove group story"),
onClick = {
StoryDialogs.removeGroupStory(requireContext(), "Family") {
Toast.makeText(requireContext(), R.string.preferences__internal_remove_group_story, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Remove group story", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_retry_send),
title = DSLSettingsText.from("Retry send"),
onClick = {
StoryDialogs.resendStory(requireContext()) {
Toast.makeText(requireContext(), R.string.preferences__internal_retry_send, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Retry send", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_story_or_profile_selector),
title = DSLSettingsText.from("Story or profile selector"),
onClick = {
StoryDialogs.displayStoryOrProfileImage(
context = requireContext(),
@@ -46,37 +46,37 @@ class StoryDialogLauncherFragment : DSLSettingsFragment(titleId = R.string.prefe
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_hide_story),
title = DSLSettingsText.from("Hide story"),
onClick = {
StoryDialogs.hideStory(requireContext(), "Spiderman") {
Toast.makeText(requireContext(), R.string.preferences__internal_hide_story, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Hide story", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_turn_off_stories),
title = DSLSettingsText.from("Turn off stories"),
onClick = {
StoryDialogs.disableStories(requireContext(), false) {
Toast.makeText(requireContext(), R.string.preferences__internal_turn_off_stories, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Turn off stories", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_turn_off_stories_with_stories_on_disk),
title = DSLSettingsText.from("Turn off stories (with stories on disk)"),
onClick = {
StoryDialogs.disableStories(requireContext(), true) {
Toast.makeText(requireContext(), R.string.preferences__internal_turn_off_stories_with_stories_on_disk, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Turn off stories (with stories on disk)", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_delete_custom_story),
title = DSLSettingsText.from("Delete custom story"),
onClick = {
StoryDialogs.deleteDistributionList(requireContext(), "Family") {
Toast.makeText(requireContext(), R.string.preferences__internal_delete_custom_story, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), "Delete custom story", Toast.LENGTH_SHORT).show()
}
}
)

View File

@@ -3,7 +3,6 @@ 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.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
@@ -12,9 +11,9 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
class DonorErrorConfigurationFragment : DSLSettingsFragment() {
class InternalDonorErrorConfigurationFragment : DSLSettingsFragment() {
private val viewModel: DonorErrorConfigurationViewModel by viewModels()
private val viewModel: InternalDonorErrorConfigurationViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: MappingAdapter) {
@@ -23,17 +22,17 @@ class DonorErrorConfigurationFragment : DSLSettingsFragment() {
}
}
private fun getConfiguration(state: DonorErrorConfigurationState): DSLConfiguration {
private fun getConfiguration(state: InternalDonorErrorConfigurationState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_expired_badge),
title = DSLSettingsText.from("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),
title = DSLSettingsText.from("Cancellation Reason"),
selected = UnexpectedSubscriptionCancellation.values().indexOf(state.selectedUnexpectedSubscriptionCancellation),
listItems = UnexpectedSubscriptionCancellation.values().map { it.status }.toTypedArray(),
onSelected = { viewModel.setSelectedUnexpectedSubscriptionCancellation(it) },
@@ -41,7 +40,7 @@ class DonorErrorConfigurationFragment : DSLSettingsFragment() {
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_charge_failure),
title = DSLSettingsText.from("Charge Failure"),
selected = StripeDeclineCode.Code.values().indexOf(state.selectedStripeDeclineCode),
listItems = StripeDeclineCode.Code.values().map { it.code }.toTypedArray(),
onSelected = { viewModel.setStripeDeclineCode(it) },
@@ -49,16 +48,16 @@ class DonorErrorConfigurationFragment : DSLSettingsFragment() {
)
primaryButton(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_save_and_finish),
text = DSLSettingsText.from("Save and Finish"),
onClick = {
lifecycleDisposable += viewModel.save().subscribe { requireActivity().finish() }
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_clear),
text = DSLSettingsText.from("Clear"),
onClick = {
lifecycleDisposable += viewModel.clear().subscribe()
lifecycleDisposable += viewModel.clearErrorState().subscribe()
}
)
}

View File

@@ -4,7 +4,7 @@ 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(
data class InternalDonorErrorConfigurationState(
val badges: List<Badge> = emptyList(),
val selectedBadge: Badge? = null,
val selectedUnexpectedSubscriptionCancellation: UnexpectedSubscriptionCancellation? = null,

View File

@@ -18,12 +18,12 @@ import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Locale
class DonorErrorConfigurationViewModel : ViewModel() {
class InternalDonorErrorConfigurationViewModel : ViewModel() {
private val store = RxStore(DonorErrorConfigurationState())
private val store = RxStore(InternalDonorErrorConfigurationState())
private val disposables = CompositeDisposable()
val state: Flowable<DonorErrorConfigurationState> = store.stateFlowable
val state: Flowable<InternalDonorErrorConfigurationState> = store.stateFlowable
init {
val giftBadges: Single<List<Badge>> = Single
@@ -108,10 +108,10 @@ class DonorErrorConfigurationViewModel : ViewModel() {
}
}.subscribeOn(Schedulers.io())
return clear().andThen(saveState)
return clearErrorState().andThen(saveState)
}
fun clear(): Completable {
fun clearErrorState(): Completable {
return Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
SignalStore.donationsValues().setExpiredBadge(null)
@@ -131,20 +131,20 @@ class DonorErrorConfigurationViewModel : ViewModel() {
}
}
private fun handleBoostExpiration(state: DonorErrorConfigurationState) {
private fun handleBoostExpiration(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
}
private fun handleGiftExpiration(state: DonorErrorConfigurationState) {
private fun handleGiftExpiration(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredGiftBadge(state.selectedBadge)
}
private fun handleSubscriptionExpiration(state: DonorErrorConfigurationState) {
private fun handleSubscriptionExpiration(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
handleSubscriptionPaymentFailure(state)
}
private fun handleSubscriptionPaymentFailure(state: DonorErrorConfigurationState) {
private fun handleSubscriptionPaymentFailure(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(

View File

@@ -6,7 +6,7 @@ import io.reactivex.rxjava3.subjects.Subject
import kotlinx.parcelize.Parcelize
interface DonationPaymentComponent {
val donationPaymentRepository: DonationPaymentRepository
val stripeRepository: StripeRepository
val googlePayResultPublisher: Subject<GooglePayResult>
@Parcelize

View File

@@ -1,426 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import android.app.Activity
import android.content.Intent
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.ProfileUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Manages bindings with payment APIs
*
* Steps for setting up payments for a subscription:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
* 1. Create a SetupIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts and Gifts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the PaymentIntent via the Stripe API
*/
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
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())
fun isGooglePayAvailable(): Completable {
return googlePayApi.queryIsReadyToPay()
}
fun scheduleSyncForAccountRecordChange() {
SignalExecutors.BOUNDED.execute {
scheduleSyncForAccountRecordChangeSync()
}
}
private fun scheduleSyncForAccountRecordChangeSync() {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
Log.d(TAG, "Requesting a token from google pay...")
googlePayApi.requestPayment(price, label, requestCode)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
expectedRequestCode: Int,
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
) {
Log.d(TAG, "Processing possible google pay result...")
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
/**
* Verifies that the given recipient is a supported target for a gift.
*/
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, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
try {
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
if (!profile.profile.capabilities.isGiftBadges) {
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
} else {
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
}
} catch (e: IOException) {
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
}
}.subscribeOn(Schedulers.io())
}
/**
* @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,
paymentSource: StripeApi.PaymentSource,
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)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, it))
}
}
.flatMapCompletable { result ->
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentSource, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
}.subscribeOn(Schedulers.io())
}
fun continueSubscriptionSetup(paymentSource: StripeApi.PaymentSource): Completable {
Log.d(TAG, "Continuing subscription setup...", true)
return stripeApi.createSetupIntent()
.flatMapCompletable { result ->
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent).doOnComplete {
Log.d(TAG, "Confirmed SetupIntent...", true)
}
}
}
fun cancelActiveSubscription(): Completable {
Log.d(TAG, "Canceling active subscription...", true)
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return Single
.fromCallable {
ApplicationDependencies.getDonationsService()
.cancelSubscription(localSubscriber.subscriberId)
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
.ignoreElement()
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
}
fun ensureSubscriberId(): Completable {
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
return Single
.fromCallable {
ApplicationDependencies
.getDonationsService()
.putSubscription(subscriberId)
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
.doOnComplete {
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
SignalStore
.donationsValues()
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
scheduleSyncForAccountRecordChangeSync()
}
}
private fun confirmPayment(price: FiatMoney, paymentSource: StripeApi.PaymentSource, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Confirming payment intent...", true)
val confirmPayment = stripeApi.confirmPaymentIntent(paymentSource, paymentIntent).onErrorResumeNext {
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
}
val waitOnRedemption = Completable.create {
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(price)
} else {
DonationReceiptRecord.createForGift(price)
}
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)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntent)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
chain.enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
}
else -> {
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
} else {
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
} catch (e: InterruptedException) {
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
return confirmPayment.andThen(waitOnRedemption)
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
Single
.fromCallable {
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
subscriber.subscriberId,
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize(),
SubscriptionReceiptRequestResponseJob.MUTEX
)
}
.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().updateLocalStateForLocalSubscribe()
scheduleSyncForAccountRecordChange()
LevelUpdate.updateProcessingState(false)
Completable.complete()
} else {
if (it.applicationError.isPresent) {
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true)
SignalStore.donationsValues().clearLevelOperations()
} else {
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true)
}
LevelUpdate.updateProcessingState(false)
it.flattenResult().ignoreElement()
}
}.andThen {
Log.d(TAG, "Enqueuing request response job chain.", true)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Subscription request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
}.doOnError {
LevelUpdate.updateProcessingState(false)
}.subscribeOn(Schedulers.io())
}
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
if (levelUpdateOperation == null) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
SignalStore.donationsValues().setLevelOperation(newOperation)
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Created a new operation for $subscriptionLevel")
newOperation
} else {
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Reusing operation for $subscriptionLevel")
levelUpdateOperation
}
}
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeApi.PaymentIntent> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return Single
.fromCallable {
ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
}
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map {
StripeApi.PaymentIntent(it.id, it.clientSecret)
}.doOnSuccess {
Log.d(TAG, "Got payment intent from Signal service!")
}
}
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
Log.d(TAG, "Fetching setup intent from Signal service...")
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
.flatMap {
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.createSubscriptionPaymentMethod(it.subscriberId)
}
}
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map { StripeApi.SetupIntent(it.id, it.clientSecret) }
.doOnSuccess {
Log.d(TAG, "Got setup intent from Signal service!")
}
}
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
Log.d(TAG, "Setting default payment method via Signal service...")
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method via Signal service!")
}
}
companion object {
private val TAG = Log.tag(DonationPaymentRepository::class.java)
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LocaleFeatureFlags
import org.thoughtcrime.securesms.util.PlayServicesUtil
/**
* Helper object to determine in-app donations availability.
*/
object InAppDonations {
/**
* The user is:
*
* - Able to use Credit Cards and is in a region where they are able to be accepted.
* - Able to access Google Play services (and thus possibly able to use Google Pay).
* - Able to use PayPal and is in a region where it is able to be accepted.
*/
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable()
}
/**
* Whether the user is in a region that supports credit cards, based off local phone number.
*/
fun isCreditCardAvailable(): Boolean {
return FeatureFlags.creditCardPayments() && !LocaleFeatureFlags.isCreditCardDisabled()
}
/**
* Whether the user is in a region that supports PayPal, based off local phone number.
*/
fun isPayPalAvailable(): Boolean {
return false
}
/**
* Whether the user is in a region that supports GooglePay, based off local phone number.
*/
private fun isGooglePayAvailable(): Boolean {
return isPlayServicesAvailable() && !LocaleFeatureFlags.isGooglePayDisabled()
}
/**
* Whether Play Services is available. This will *not* tell you whether a user has Google Pay set up, but is
* enough information to determine whether we can display Google Pay as an option.
*/
private fun isPlayServicesAvailable(): Boolean {
return PlayServicesUtil.getPlayServicesStatus(ApplicationDependencies.getApplication()) == PlayServicesUtil.PlayServicesStatus.SUCCESS
}
}

View File

@@ -0,0 +1,229 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class MonthlyDonationRepository(private val donationsService: DonationsService) {
private val TAG = Log.tag(MonthlyDonationRepository::class.java)
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
} else {
Single.just(ActiveSubscription.EMPTY)
}
}
fun getSubscriptions(): Single<List<Subscription>> = Single
.fromCallable { donationsService.getSubscriptionLevels(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
.map { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->
Subscription(
id = code,
name = level.name,
badge = Badges.fromServiceBadge(level.badge),
prices = level.currencies.filter {
PlatformCurrencyUtil
.getAvailableCurrencyCodes()
.contains(it.key)
}.map { (currencyCode, price) ->
FiatMoney(price, Currency.getInstance(currencyCode))
}.toSet(),
level = code.toInt()
)
}.sortedBy {
it.level
}
}
fun syncAccountRecord(): Completable {
return Completable.fromAction {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}.subscribeOn(Schedulers.io())
}
fun ensureSubscriberId(): Completable {
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
return Single
.fromCallable {
donationsService.putSubscription(subscriberId)
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
.doOnComplete {
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
SignalStore
.donationsValues()
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun cancelActiveSubscription(): Completable {
Log.d(TAG, "Canceling active subscription...", true)
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return Single
.fromCallable {
donationsService.cancelSubscription(localSubscriber.subscriberId)
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
.ignoreElement()
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
}
fun cancelActiveSubscriptionIfNecessary(): Completable {
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
if (it) {
Log.d(TAG, "Cancelling active subscription...", true)
cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
}
} else {
Completable.complete()
}
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
Single
.fromCallable {
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
subscriber.subscriberId,
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize(),
SubscriptionReceiptRequestResponseJob.MUTEX
)
}
.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().updateLocalStateForLocalSubscribe()
syncAccountRecord().subscribe()
LevelUpdate.updateProcessingState(false)
Completable.complete()
} else {
if (it.applicationError.isPresent) {
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true)
SignalStore.donationsValues().clearLevelOperations()
} else {
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true)
}
LevelUpdate.updateProcessingState(false)
it.flattenResult().ignoreElement()
}
}.andThen {
Log.d(TAG, "Enqueuing request response job chain.", true)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Subscription request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
}.doOnError {
LevelUpdate.updateProcessingState(false)
}.subscribeOn(Schedulers.io())
}
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
if (levelUpdateOperation == null) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
SignalStore.donationsValues().setLevelOperation(newOperation)
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Created a new operation for $subscriptionLevel")
newOperation
} else {
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Reusing operation for $subscriptionLevel")
levelUpdateOperation
}
}
}

View File

@@ -0,0 +1,124 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.ServiceResponse
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class OneTimeDonationRepository(private val donationsService: DonationsService) {
companion object {
private val TAG = Log.tag(OneTimeDonationRepository::class.java)
}
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return Single.fromCallable { donationsService.boostAmounts }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
.map { result ->
result
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
.mapKeys { (code, _) -> Currency.getInstance(code) }
.mapValues { (currency, prices) -> prices.map { Boost(FiatMoney(it, currency)) } }
}
}
fun getBoostBadge(): Single<Badge> {
return Single
.fromCallable {
ApplicationDependencies.getDonationsService()
.getBoostBadge(Locale.getDefault())
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
.map(Badges::fromServiceBadge)
}
fun waitForOneTimeRedemption(
price: FiatMoney,
paymentIntentId: String,
badgeRecipient: RecipientId,
additionalMessage: String?,
badgeLevel: Long,
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
val waitOnRedemption = Completable.create {
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(price)
} else {
DonationReceiptRecord.createForGift(price)
}
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)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel)
}
chain.enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
}
else -> {
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
} else {
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
} catch (e: InterruptedException) {
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
return waitOnRedemption
}
}

View File

@@ -0,0 +1,237 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import android.app.Activity
import android.content.Intent
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.signal.donations.json.StripeIntentStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
/**
* Manages bindings with payment APIs
*
* Steps for setting up payments for a subscription:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
* 1. Create a SetupIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts and Gifts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the PaymentIntent via the Stripe API
*/
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
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())
fun isGooglePayAvailable(): Completable {
return googlePayApi.queryIsReadyToPay()
}
fun scheduleSyncForAccountRecordChange() {
SignalExecutors.BOUNDED.execute {
scheduleSyncForAccountRecordChangeSync()
}
}
private fun scheduleSyncForAccountRecordChangeSync() {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
Log.d(TAG, "Requesting a token from google pay...")
googlePayApi.requestPayment(price, label, requestCode)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
expectedRequestCode: Int,
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
) {
Log.d(TAG, "Processing possible google pay result...")
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
/**
* @param price The amount to charce the local user
* @param badgeRecipient Who will be getting the badge
*/
fun continuePayment(
price: FiatMoney,
badgeRecipient: RecipientId,
badgeLevel: Long,
): Single<StripeIntentAccessor> {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, badgeLevel)
.onErrorResumeNext {
handleCreatePaymentIntentError(it, badgeRecipient)
}
.flatMap { result ->
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
}
}.subscribeOn(Schedulers.io())
}
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
Log.d(TAG, "Continuing subscription setup...", true)
return stripeApi.createSetupIntent()
.flatMap { result ->
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
}
}
fun confirmPayment(
paymentSource: StripeApi.PaymentSource,
paymentIntent: StripeIntentAccessor,
badgeRecipient: RecipientId
): Single<StripeApi.Secure3DSAction> {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Confirming payment intent...", true)
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
.onErrorResumeNext {
Single.error(DonationError.getPaymentSetupError(donationErrorSource, it))
}
}
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return Single
.fromCallable {
ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
}
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
.map {
StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = it.id,
intentClientSecret = it.clientSecret
)
}.doOnSuccess {
Log.d(TAG, "Got payment intent from Signal service!")
}
}
override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching setup intent from Signal service...")
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
.flatMap {
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.createStripeSubscriptionPaymentMethod(it.subscriberId)
}
}
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
.map {
StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
intentId = it.id,
intentClientSecret = it.clientSecret
)
}
.doOnSuccess {
Log.d(TAG, "Got setup intent from Signal service!")
}
}
// We need to get the status and payment id from the intent.
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
}
}
}
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable {
Log.d(TAG, "Getting the subscriber...")
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
Log.d(TAG, "Setting default payment method via Signal service...")
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method via Signal service!")
}
}
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating credit card payment source via Stripe api...")
return stripeApi.createPaymentSourceFromCardData(cardData).map {
when (it) {
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason)
is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource
}
}
}
data class StatusAndPaymentMethodId(
val status: StripeIntentStatus,
val paymentMethod: String?
)
companion object {
private val TAG = Log.tag(StripeRepository::class.java)
fun <T> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId): Single<T> {
return if (throwable is DonationError) {
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable))
}
}
}
}

View File

@@ -1,68 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
import java.util.Locale
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class SubscriptionsRepository(private val donationsService: DonationsService) {
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
} else {
Single.just(ActiveSubscription.EMPTY)
}
}
fun getSubscriptions(): Single<List<Subscription>> = Single
.fromCallable { donationsService.getSubscriptionLevels(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
.map { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->
Subscription(
id = code,
name = level.name,
badge = Badges.fromServiceBadge(level.badge),
prices = level.currencies.filter {
PlatformCurrencyUtil
.getAvailableCurrencyCodes()
.contains(it.key)
}.map { (currencyCode, price) ->
FiatMoney(price, Currency.getInstance(currencyCode))
}.toSet(),
level = code.toInt()
)
}.sortedBy {
it.level
}
}
fun syncAccountRecord(): Completable {
return Completable.fromAction {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -1,45 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.animation.Animator
import android.view.View
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* A simple mapping model to show a boost animation.
*/
object BoostAnimation {
class Model : PreferenceModel<Model>(isEnabled = true) {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val lottie: LottieAnimationView = findViewById(R.id.boost_animation_view)
override fun bind(model: Model) {
lottie.playAnimation()
lottie.addAnimatorListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) {
lottie.removeAnimatorListener(this)
lottie.setMinAndMaxFrame(30, 91)
lottie.repeatMode = LottieDrawable.RESTART
lottie.repeatCount = LottieDrawable.INFINITE
lottie.frame = 30
lottie.playAnimation()
}
})
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.boost_animation_pref))
}
}

View File

@@ -1,41 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.ServiceResponse
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
class BoostRepository(private val donationsService: DonationsService) {
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return Single.fromCallable { donationsService.boostAmounts }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
.map { result ->
result
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
.mapKeys { (code, _) -> Currency.getInstance(code) }
.mapValues { (currency, prices) -> prices.map { Boost(FiatMoney(it, currency)) } }
}
}
fun getBoostBadge(): Single<Badge> {
return Single
.fromCallable {
ApplicationDependencies.getDonationsService()
.getBoostBadge(Locale.getDefault())
}
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
.map(Badges::fromServiceBadge)
}
}

View File

@@ -40,7 +40,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
summary = DSLSettingsText.from(currency.currencyCode),
onClick = {
viewModel.setSelectedCurrency(currency.currencyCode)
donationPaymentComponent.donationPaymentRepository.scheduleSyncForAccountRecordChange()
donationPaymentComponent.stripeRepository.scheduleSyncForAccountRecordChange()
dismissAllowingStateLoss()
}
)

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.content.Intent
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
/**
* Activity wrapper for donate to signal screen. An activity is needed because Google Pay uses the
* activity [DonateToSignalActivity.startActivityForResult] flow that would be missed by a parent fragment.
*/
class DonateToSignalActivity : FragmentWrapperActivity(), DonationPaymentComponent {
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
override fun getFragment(): Fragment {
return DonateToSignalFragment().apply {
arguments = DonateToSignalFragmentArgs.Builder(DonateToSignalType.ONE_TIME).build().toBundle()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.content.DialogInterface
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
@@ -18,6 +19,7 @@ import com.airbnb.lottie.LottieAnimationView
import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
@@ -32,12 +34,13 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
@@ -49,7 +52,6 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.Projection
@@ -89,6 +91,8 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
private var errorDialog: DialogInterface? = null
private val args: DonateToSignalFragmentArgs by navArgs()
private val viewModel: DonateToSignalViewModel by viewModels(factoryProducer = {
DonateToSignalViewModel.Factory(args.startType)
@@ -98,7 +102,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
R.id.donate_to_signal,
factoryProducer = {
donationPaymentComponent = requireListener()
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.donationPaymentRepository)
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository)
}
)
@@ -108,7 +112,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
private lateinit var donationPaymentComponent: DonationPaymentComponent
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__support_technology)))
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
.append(" ")
.append(
SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary)) {
@@ -118,7 +122,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
override fun onToolbarNavigationClicked() {
findNavController().popBackStack()
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
@@ -138,8 +142,13 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: StripeActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
handleStripeActionResult(result)
val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
handleDonationProcessorActionResult(result)
}
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
handleCreditCardResult(result)
}
val recyclerView = this.recyclerView!!
@@ -166,13 +175,17 @@ class DonateToSignalFragment : DSLSettingsFragment(
disposables.bindTo(viewLifecycleOwner)
disposables += DonationError.getErrorsForSource(DonationErrorSource.BOOST).subscribe { error ->
showErrorDialog(error)
}
disposables += DonationError.getErrorsForSource(DonationErrorSource.BOOST)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { error ->
showErrorDialog(error)
}
disposables += DonationError.getErrorsForSource(DonationErrorSource.SUBSCRIPTION).subscribe { error ->
showErrorDialog(error)
}
disposables += DonationError.getErrorsForSource(DonationErrorSource.SUBSCRIPTION)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { error ->
showErrorDialog(error)
}
disposables += viewModel.actions.subscribe { action ->
when (action) {
@@ -193,7 +206,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
is DonateToSignalAction.CancelSubscription -> {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
StripeAction.CANCEL_SUBSCRIPTION,
DonationProcessorAction.CANCEL_SUBSCRIPTION,
action.gatewayRequest
)
)
@@ -201,7 +214,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
is DonateToSignalAction.UpdateSubscription -> {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
StripeAction.UPDATE_SUBSCRIPTION,
DonationProcessorAction.UPDATE_SUBSCRIPTION,
action.gatewayRequest
)
)
@@ -214,20 +227,39 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
override fun onStop() {
super.onStop()
listOf(
binding.boost1Animation,
binding.boost2Animation,
binding.boost3Animation,
binding.boost4Animation,
binding.boost5Animation,
binding.boost6Animation
).forEach {
it.cancelAnimation()
}
}
private fun getConfiguration(state: DonateToSignalState): DSLConfiguration {
return configure {
space(36.dp)
customPref(BadgePreview.BadgeModel.SubscriptionModel(state.badge))
space(12.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.DonateToSignalFragment__powered_by,
R.string.DonateToSignalFragment__privacy_over_profit,
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)
space(8.dp)
noPadTextPref(
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
)
@@ -268,7 +300,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
if (state.donateToSignalType == DonateToSignalType.MONTHLY && state.monthlyDonationState.isSubscriptionActive) {
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
isEnabled = state.canContinue,
isEnabled = state.canUpdate,
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__update_subscription_question)
@@ -395,25 +427,31 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
private fun handleStripeActionResult(result: StripeActionResult) {
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
Log.d(TAG, "Received credit card information from fragment.")
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
}
private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) {
when (result.status) {
StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result)
StripeActionResult.Status.FAILURE -> handleFailedStripeActionResult(result)
DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result)
DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result)
}
viewModel.refreshActiveSubscription()
}
private fun handleSuccessfulStripeActionResult(result: StripeActionResult) {
if (result.action == StripeAction.CANCEL_SUBSCRIPTION) {
private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge))
}
}
private fun handleFailedStripeActionResult(result: StripeActionResult) {
if (result.action == StripeAction.CANCEL_SUBSCRIPTION) {
private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
@@ -428,7 +466,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
donationPaymentComponent.donationPaymentRepository.requestTokenFromGooglePay(
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
label = gatewayResponse.request.label,
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
@@ -436,7 +474,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
if (FeatureFlags.creditCardPayments()) {
if (InAppDonations.isCreditCardAvailable()) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayResponse.request))
} else {
error("Credit cards are not currently enabled.")
@@ -444,10 +482,10 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
private fun registerGooglePayCallback() {
donationPaymentComponent.googlePayResultPublisher.subscribeBy(
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
viewModel.consumeGatewayRequestForGooglePay()?.let {
donationPaymentComponent.donationPaymentRepository.onActivityResult(
donationPaymentComponent.stripeRepository.onActivityResult(
paymentResult.requestCode,
paymentResult.resultCode,
paymentResult.data,
@@ -460,15 +498,20 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
private fun showErrorDialog(throwable: Throwable) {
Log.d(TAG, "Displaying donation error dialog.", true)
DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
} else {
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
errorDialog = null
findNavController().popBackStack()
}
}
}
)
)
}
}
private fun startAnimationAboveSelectedBoost(view: View) {
@@ -504,7 +547,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
override fun onSuccess(paymentData: PaymentData) {
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
stripePaymentViewModel.providePaymentData(paymentData)
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, request))
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, request))
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
@@ -54,7 +55,13 @@ data class DonateToSignalState(
val canContinue: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
}
val canUpdate: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
}

View File

@@ -12,9 +12,9 @@ import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.SubscriptionRedemptionJobWatcher
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -42,8 +42,8 @@ import java.util.Currency
*/
class DonateToSignalViewModel(
startType: DonateToSignalType,
private val subscriptionsRepository: SubscriptionsRepository,
private val boostRepository: BoostRepository
private val subscriptionsRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModel() {
companion object {
@@ -63,7 +63,7 @@ class DonateToSignalViewModel(
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
init {
initializeOneTimeDonationState(boostRepository)
initializeOneTimeDonationState(oneTimeDonationRepository)
initializeMonthlyDonationState(subscriptionsRepository)
networkDisposable += InternetConnectionObserver
@@ -87,7 +87,7 @@ class DonateToSignalViewModel(
fun retryOneTimeDonationState() {
if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) {
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) }
initializeOneTimeDonationState(boostRepository)
initializeOneTimeDonationState(oneTimeDonationRepository)
}
}
@@ -197,8 +197,8 @@ class DonateToSignalViewModel(
}
}
private fun initializeOneTimeDonationState(boostRepository: BoostRepository) {
oneTimeDonationDisposables += boostRepository.getBoostBadge().subscribeBy(
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
onSuccess = { badge ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
},
@@ -207,7 +207,7 @@ class DonateToSignalViewModel(
}
)
val boosts: Observable<Map<Currency, List<Boost>>> = boostRepository.getBoosts().toObservable()
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeDonationRepository.getBoosts().toObservable()
val oneTimeCurrency: Observable<Currency> = SignalStore.donationsValues().observableOneTimeCurrency
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
@@ -243,7 +243,7 @@ class DonateToSignalViewModel(
)
}
private fun initializeMonthlyDonationState(subscriptionsRepository: SubscriptionsRepository) {
private fun initializeMonthlyDonationState(subscriptionsRepository: MonthlyDonationRepository) {
monitorLevelUpdateProcessing()
val allSubscriptions = subscriptionsRepository.getSubscriptions()
@@ -362,11 +362,11 @@ class DonateToSignalViewModel(
class Factory(
private val startType: DonateToSignalType,
private val subscriptionsRepository: SubscriptionsRepository = SubscriptionsRepository(ApplicationDependencies.getDonationsService()),
private val boostRepository: BoostRepository = BoostRepository(ApplicationDependencies.getDonationsService())
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, boostRepository)) as T
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeDonationRepository)) as T
}
}
}

View File

@@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
enum class StripeAction : Parcelable {
enum class DonationProcessorAction : Parcelable {
PROCESS_NEW_DONATION,
UPDATE_SUBSCRIPTION,
CANCEL_SUBSCRIPTION

View File

@@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
@Parcelize
class StripeActionResult(
val action: StripeAction,
class DonationProcessorActionResult(
val action: DonationProcessorAction,
val request: GatewayRequest,
val status: Status
) : Parcelable {

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
enum class DonationProcessorStage {
INIT,
PAYMENT_PIPELINE,
CANCELLING,
FAILED,
COMPLETE;
val isInProgress: Boolean get() = this == PAYMENT_PIPELINE || this == CANCELLING
val isTerminal: Boolean get() = this == FAILED || this == COMPLETE
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import android.text.Editable
import android.text.TextWatcher
class CreditCardExpirationTextWatcher : TextWatcher {
private var isBackspace = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
isBackspace = count == 0
}
override fun afterTextChanged(s: Editable) {
val text = s.toString()
val formattedText = when (text.length) {
1 -> formatForSingleCharacter(text)
2 -> formatForTwoCharacters(text)
else -> text
}
val finalText = if (isBackspace && text.length < formattedText.length && formattedText.endsWith("/")) {
formattedText.dropLast(2)
} else {
formattedText
}
if (finalText != text) {
s.replace(0, s.length, finalText)
}
}
private fun formatForSingleCharacter(text: String): String {
val number = text.toIntOrNull() ?: return text
return if (number > 1) {
"0$number/"
} else {
text
}
}
private fun formatForTwoCharacters(text: String): String {
val number = text.toIntOrNull() ?: return text
return if (number <= 12) {
"%02d/".format(number)
} else {
text
}
}
}

View File

@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import org.signal.donations.StripeApi
data class CreditCardFormState(
val focusedField: FocusedField = FocusedField.NONE,
val number: String = "",
@@ -12,4 +14,13 @@ data class CreditCardFormState(
EXPIRATION,
CODE
}
fun toCardData(): StripeApi.CardData {
return StripeApi.CardData(
number,
expiration.month.toInt(),
expiration.year.toInt(),
code.toInt()
)
}
}

View File

@@ -3,16 +3,27 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager.LayoutParams
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
@@ -22,10 +33,27 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.title.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.CreditCardFragment__donation_amount_s_per_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat))
}
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
binding.description.setLearnMoreVisible(true)
binding.description.setOnLinkClickListener {
findNavController().safeNavigate(CreditCardFragmentDirections.actionCreditCardFragmentToYourInformationIsPrivateBottomSheet())
}
binding.cardNumber.addTextChangedListener(afterTextChanged = {
viewModel.onNumberChanged(it?.toString() ?: "")
viewModel.onNumberChanged(it?.toString()?.filter { it != ' ' } ?: "")
})
binding.cardNumber.addTextChangedListener(CreditCardTextWatcher())
binding.cardNumber.setOnFocusChangeListener { v, hasFocus ->
viewModel.onNumberFocusChanged(hasFocus)
}
@@ -38,24 +66,59 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
viewModel.onCodeFocusChanged(hasFocus)
}
binding.cardCvv.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
binding.continueButton.performClick()
true
} else {
false
}
}
binding.cardExpiry.addTextChangedListener(afterTextChanged = {
viewModel.onExpirationChanged(it?.toString() ?: "")
})
binding.cardExpiry.addTextChangedListener(CreditCardExpirationTextWatcher())
binding.cardExpiry.setOnFocusChangeListener { v, hasFocus ->
viewModel.onExpirationFocusChanged(hasFocus)
}
binding.continueButton.setOnClickListener {
findNavController().popBackStack()
val resultBundle = bundleOf(
REQUEST_KEY to CreditCardResult(
args.request,
viewModel.getCardData()
)
)
setFragmentResult(REQUEST_KEY, resultBundle)
}
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.subscribe {
// TODO [alex] -- type
// TODO [alex] -- all fields valid
presentContinue(it)
presentCardNumberWrapper(it.numberValidity)
presentCardExpiryWrapper(it.expirationValidity)
presentCardCodeWrapper(it.codeValidity)
}
}
override fun onStart() {
super.onStart()
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
override fun onResume() {
super.onResume()
@@ -67,6 +130,17 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
override fun onStop() {
super.onStop()
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun presentContinue(state: CreditCardValidationState) {
binding.continueButton.isEnabled = state.isValid
}
private fun presentCardNumberWrapper(validity: CreditCardNumberValidator.Validity) {
val errorState = when (validity) {
CreditCardNumberValidator.Validity.INVALID -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_card_number)
@@ -84,7 +158,13 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
CreditCardExpirationValidator.Validity.INVALID_MONTH -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_month)
CreditCardExpirationValidator.Validity.INVALID_YEAR -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_year)
CreditCardExpirationValidator.Validity.POTENTIALLY_VALID -> NO_ERROR
CreditCardExpirationValidator.Validity.FULLY_VALID -> NO_ERROR
CreditCardExpirationValidator.Validity.FULLY_VALID -> {
if (binding.cardExpiry.isFocused) {
binding.cardCvv.requestFocus()
}
NO_ERROR
}
}
binding.cardExpiryWrapper.error = errorState.resolveErrorText(requireContext())
@@ -116,6 +196,8 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
companion object {
val REQUEST_KEY = "card.data"
private val NO_ERROR = ErrorState(false, -1)
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
/**
* Encapsulates data returned from the credit card form that can be used
* for a credit card based donation payment.
*/
@Parcelize
data class CreditCardResult(
val gatewayRequest: GatewayRequest,
val creditCardData: StripeApi.CardData
) : Parcelable

View File

@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import android.text.Editable
import android.text.TextWatcher
/**
* Formats a credit card by type as the user modifies it.
*/
class CreditCardTextWatcher : TextWatcher {
private var isBackspace: Boolean = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
isBackspace = count == 0
}
override fun afterTextChanged(s: Editable) {
val userInput = s.toString()
val normalizedNumber = userInput.filter { it != ' ' }
val formattedNumber = when (CreditCardType.fromCardNumber(normalizedNumber)) {
CreditCardType.AMERICAN_EXPRESS -> applyAmexFormatting(normalizedNumber)
CreditCardType.UNIONPAY -> applyUnionPayFormatting(normalizedNumber)
CreditCardType.OTHER -> applyOtherFormatting(normalizedNumber)
}
val backspaceHandled = if (isBackspace && formattedNumber.endsWith(' ') && formattedNumber.length > userInput.length) {
formattedNumber.dropLast(2)
} else {
formattedNumber
}
if (userInput != backspaceHandled) {
s.replace(0, s.length, backspaceHandled)
}
}
private fun applyAmexFormatting(normalizedNumber: String): String {
return applyGrouping(normalizedNumber, listOf(4, 6, 5))
}
private fun applyUnionPayFormatting(normalizedNumber: String): String {
return when {
normalizedNumber.length <= 13 -> applyGrouping(normalizedNumber, listOf(4, 4, 5))
normalizedNumber.length <= 16 -> applyGrouping(normalizedNumber, listOf(4, 4, 4, 4))
else -> applyGrouping(normalizedNumber, listOf(5, 5, 5, 4))
}
}
private fun applyOtherFormatting(normalizedNumber: String): String {
return if (normalizedNumber.length <= 16) {
applyGrouping(normalizedNumber, listOf(4, 4, 4, 4))
} else {
applyGrouping(normalizedNumber, listOf(5, 5, 5, 4))
}
}
private fun applyGrouping(normalizedNumber: String, groups: List<Int>): String {
val maxCardLength = groups.sum()
return groups.fold(0 to emptyList<String>()) { acc, limit ->
val offset = acc.first
val section = normalizedNumber.drop(offset).take(limit)
val segment = if (limit == section.length && offset + limit != maxCardLength) {
"$section "
} else {
section
}
(offset + limit) to acc.second + segment
}.second.filter { it.isNotEmpty() }.joinToString("")
}
}

View File

@@ -5,4 +5,9 @@ data class CreditCardValidationState(
val numberValidity: CreditCardNumberValidator.Validity,
val expirationValidity: CreditCardExpirationValidator.Validity,
val codeValidity: CreditCardCodeValidator.Validity
)
) {
val isValid: Boolean =
numberValidity == CreditCardNumberValidator.Validity.FULLY_VALID &&
expirationValidity == CreditCardExpirationValidator.Validity.FULLY_VALID &&
codeValidity == CreditCardCodeValidator.Validity.FULLY_VALID
}

View File

@@ -6,6 +6,7 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.BehaviorProcessor
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Calendar
@@ -76,6 +77,10 @@ class CreditCardViewModel : ViewModel() {
updateFocus(CreditCardFormState.FocusedField.CODE, isFocused)
}
fun getCardData(): StripeApi.CardData {
return formStore.state.toCardData()
}
private fun updateFocus(
newFocusedField: CreditCardFormState.FocusedField,
isFocused: Boolean

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import org.signal.core.util.dp
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.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
/**
* Displays information about how Signal keeps card details private and how
* Signal does not link donation information to your Signal account.
*/
class YourInformationIsPrivateBottomSheet : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
space(10.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.YourInformationIsPrivateBottomSheet__your_information_is_private,
DSLSettingsText.CenterModifier,
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_HeadlineMedium)
)
)
space(24.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.YourInformationIsPrivateBottomSheet__signal_does_not_collect,
DSLSettingsText.BodyLargeModifier
)
)
space(24.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.YourInformationIsPrivateBottomSheet__we_use_stripe,
DSLSettingsText.BodyLargeModifier
)
)
space(24.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.YourInformationIsPrivateBottomSheet__signal_does_not_and_cannot,
DSLSettingsText.BodyLargeModifier
)
)
space(24.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.YourInformationIsPrivateBottomSheet__thank_you,
DSLSettingsText.BodyLargeModifier
)
)
space(56.dp)
}
}
}

View File

@@ -12,8 +12,11 @@ import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
@@ -31,7 +34,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
private val args: GatewaySelectorBottomSheetArgs by navArgs()
private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = {
GatewaySelectorViewModel.Factory(args, requireListener<DonationPaymentComponent>().donationPaymentRepository)
GatewaySelectorViewModel.Factory(args, requireListener<DonationPaymentComponent>().stripeRepository)
})
override fun bindAdapter(adapter: DSLSettingsAdapter) {
@@ -61,7 +64,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
DonateToSignalType.ONE_TIME -> presentOneTimeText()
}
space(68.dp)
space(66.dp)
if (state.isGooglePayAvailable) {
customPref(
@@ -79,11 +82,12 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
// PayPal
// Credit Card
if (state.isCreditCardAvailable) {
if (InAppDonations.isCreditCardAvailable()) {
space(12.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
@@ -126,7 +130,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
space(6.dp)
noPadTextPref(
title = DSLSettingsText.from(
getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, args.request.badge.name, 30),
resources.getQuantityString(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, args.request.badge.name, 30),
DSLSettingsText.CenterModifier,
DSLSettingsText.BodyLargeModifier,
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))

View File

@@ -1,11 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.util.FeatureFlags
data class GatewaySelectorState(
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = FeatureFlags.creditCardPayments()
val isGooglePayAvailable: Boolean = false
)

View File

@@ -2,16 +2,19 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.util.rx.RxStore
class GatewaySelectorViewModel(
args: GatewaySelectorBottomSheetArgs,
private val repository: DonationPaymentRepository
private val repository: StripeRepository
) : ViewModel() {
private val store = RxStore(GatewaySelectorState(args.request.badge))
private val disposables = CompositeDisposable()
val state = store.stateFlowable
@@ -21,10 +24,11 @@ class GatewaySelectorViewModel(
override fun onCleared() {
store.dispose()
disposables.clear()
}
private fun checkIfGooglePayIsAvailable() {
repository.isGooglePayAvailable().subscribeBy(
disposables += repository.isGooglePayAvailable().subscribeBy(
onComplete = {
store.update { it.copy(isGooglePayAvailable = true) }
},
@@ -36,7 +40,7 @@ class GatewaySelectorViewModel(
class Factory(
private val args: GatewaySelectorBottomSheetArgs,
private val repository: DonationPaymentRepository
private val repository: StripeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(GatewaySelectorViewModel(args, repository)) as T

View File

@@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding
import org.thoughtcrime.securesms.util.visible
/**
* Full-screen dialog for displaying Stripe 3DS confirmation.
*/
class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragment) {
companion object {
const val REQUEST_KEY = "stripe_3ds_dialog_fragment"
}
val binding by ViewBinderDelegate(Stripe3dsDialogFragmentBinding::bind) {
it.webView.clearCache(true)
it.webView.clearHistory()
}
val args: Stripe3DSDialogFragmentArgs by navArgs()
var result: Bundle? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.webView.webViewClient = Stripe3DSWebClient()
binding.webView.settings.javaScriptEnabled = true
binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
binding.webView.loadUrl(args.uri.toString())
}
override fun onDismiss(dialog: DialogInterface) {
val result = this.result
this.result = null
setFragmentResult(REQUEST_KEY, result ?: Bundle())
}
private inner class Stripe3DSWebClient : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
binding.progress.visible = true
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
binding.progress.visible = false
}
override fun onPageFinished(view: WebView?, url: String?) {
if (url?.startsWith(args.returnUri.toString()) == true) {
val stripeIntentAccessor = StripeIntentAccessor.fromUri(url)
result = bundleOf(REQUEST_KEY to stripeIntentAccessor)
dismissAllowingStateLoss()
}
}
}
}

View File

@@ -7,21 +7,34 @@ import android.os.Bundle
import android.view.View
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) {
companion object {
private val TAG = Log.tag(StripePaymentInProgressFragment::class.java)
const val REQUEST_KEY = "REQUEST_KEY"
}
@@ -32,7 +45,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
private val viewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().donationPaymentRepository)
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
@@ -44,57 +57,90 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
disposables += viewModel.state.subscribeBy { stage ->
presentUiState(stage)
}
if (savedInstanceState == null) {
viewModel.onBeginNewAction()
when (args.action) {
StripeAction.PROCESS_NEW_DONATION -> {
viewModel.processNewDonation(args.request)
DonationProcessorAction.PROCESS_NEW_DONATION -> {
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
}
StripeAction.UPDATE_SUBSCRIPTION -> {
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request)
}
StripeAction.CANCEL_SUBSCRIPTION -> {
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
}
}
}
disposables.bindTo(viewLifecycleOwner)
disposables += viewModel.state.subscribeBy { stage ->
presentUiState(stage)
}
}
private fun presentUiState(stage: StripeStage) {
private fun presentUiState(stage: DonationProcessorStage) {
when (stage) {
StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
StripeStage.FAILED -> {
DonationProcessorStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
DonationProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
DonationProcessorStage.FAILED -> {
viewModel.onEndAction()
findNavController().popBackStack()
setFragmentResult(
REQUEST_KEY,
bundleOf(
REQUEST_KEY to StripeActionResult(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
status = StripeActionResult.Status.FAILURE
status = DonationProcessorActionResult.Status.FAILURE
)
)
)
}
StripeStage.COMPLETE -> {
DonationProcessorStage.COMPLETE -> {
viewModel.onEndAction()
findNavController().popBackStack()
setFragmentResult(
REQUEST_KEY,
bundleOf(
REQUEST_KEY to StripeActionResult(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
status = StripeActionResult.Status.SUCCESS
status = DonationProcessorActionResult.Status.SUCCESS
)
)
)
}
StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
DonationProcessorStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
}
}
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single<StripeIntentAccessor> {
return when (secure3dsAction) {
is StripeApi.Secure3DSAction.NotNeeded -> {
Log.d(TAG, "No 3DS action required.")
Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED)
}
is StripeApi.Secure3DSAction.ConfirmRequired -> {
Log.d(TAG, "3DS action required. Displaying dialog...")
Single.create<StripeIntentAccessor> { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: StripeIntentAccessor? = bundle.getParcelable(Stripe3DSDialogFragment.REQUEST_KEY)
if (result != null) {
emitter.onSuccess(result)
} else {
emitter.onError(Exception("User did not complete 3DS Authorization."))
}
}
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri))
emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
}
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
}
}
}
}

View File

@@ -12,8 +12,13 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayPaymentSource
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
@@ -26,58 +31,116 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.api.util.Preconditions
class StripePaymentInProgressViewModel(
private val donationPaymentRepository: DonationPaymentRepository
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(StripePaymentInProgressViewModel::class.java)
}
private val store = RxStore(StripeStage.INIT)
val state: Flowable<StripeStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val store = RxStore(DonationProcessorStage.INIT)
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
private var paymentData: PaymentData? = null
private var cardData: StripeApi.CardData? = null
override fun onCleared() {
disposables.clear()
store.dispose()
clearPaymentInformation()
}
fun processNewDonation(request: GatewayRequest) {
val paymentData = this.paymentData ?: error("Cannot process new donation without payment data")
this.paymentData = null
fun onBeginNewAction() {
Preconditions.checkState(!store.state.isInProgress)
Preconditions.checkState(store.state != StripeStage.PAYMENT_PIPELINE)
Log.d(TAG, "Proceeding with donation...")
Log.d(TAG, "Beginning a new action. Ensuring cleared state.", true)
disposables.clear()
}
fun onEndAction() {
Preconditions.checkState(store.state.isTerminal)
Log.d(TAG, "Ending current state. Clearing state and setting stage to INIT", true)
store.update { DonationProcessorStage.INIT }
disposables.clear()
}
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
Log.d(TAG, "Proceeding with donation...", true)
val errorSource = when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
}
val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource)
return when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentData)
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentData)
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
}
}
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): Single<StripeApi.PaymentSource> {
val paymentData = this.paymentData
val cardData = this.cardData
return when {
paymentData == null && cardData == null -> error("No payment provider available.")
paymentData != null && cardData != null -> error("Too many providers available")
paymentData != null -> Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData))
cardData != null -> stripeRepository.createCreditCardPaymentSource(errorSource, cardData)
else -> error("This should never happen.")
}.doAfterTerminate { clearPaymentInformation() }
}
fun providePaymentData(paymentData: PaymentData) {
requireNoPaymentInformation()
this.paymentData = paymentData
}
private fun proceedMonthly(request: GatewayRequest, paymentData: PaymentData) {
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(GooglePayPaymentSource(paymentData))
val setLevel = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
fun provideCardData(cardData: StripeApi.CardData) {
requireNoPaymentInformation()
this.cardData = cardData
}
private fun requireNoPaymentInformation() {
require(paymentData == null)
require(cardData == null)
}
private fun clearPaymentInformation() {
Log.d(TAG, "Cleared payment information.", true)
paymentData = null
cardData = null
}
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single<StripeApi.PaymentSource>, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { stripeRepository.createAndConfirmSetupIntent(it) }
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { StripeStage.PAYMENT_PIPELINE }
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
val setup = ensureSubscriberId
.andThen(cancelActiveSubscriptionIfNecessary())
.andThen(continueSetup)
val setup: Completable = ensureSubscriberId
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
.andThen(createAndConfirmSetupIntent)
.flatMap { secure3DSAction ->
nextActionHandler(secure3DSAction)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
setup.andThen(setLevel).subscribeBy(
disposables += setup.andThen(setLevel).subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
store.update { StripeStage.FAILED }
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
@@ -88,32 +151,34 @@ class StripePaymentInProgressViewModel(
},
onComplete = {
Log.d(TAG, "Finished subscription payment pipeline...", true)
store.update { StripeStage.COMPLETE }
store.update { DonationProcessorStage.COMPLETE }
}
)
}
private fun cancelActiveSubscriptionIfNecessary(): Completable {
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
if (it) {
Log.d(TAG, "Cancelling active subscription...", true)
donationPaymentRepository.cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
}
} else {
Completable.complete()
}
}
}
private fun proceedOneTime(request: GatewayRequest, paymentData: PaymentData) {
private fun proceedOneTime(
request: GatewayRequest,
paymentSourceProvider: Single<StripeApi.PaymentSource>,
nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>
) {
Log.w(TAG, "Beginning one-time payment pipeline...", true)
donationPaymentRepository.continuePayment(request.fiat, GooglePayPaymentSource(paymentData), Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy(
val amount = request.fiat
val recipient = Recipient.self().id
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, recipient, level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
.flatMap { nextActionHandler(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption(amount, paymentIntent.intentId, recipient, null, level) }
}.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { StripeStage.FAILED }
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
@@ -124,35 +189,39 @@ class StripePaymentInProgressViewModel(
},
onComplete = {
Log.w(TAG, "Completed one-time payment pipeline...", true)
store.update { StripeStage.COMPLETE }
store.update { DonationProcessorStage.COMPLETE }
}
)
}
fun cancelSubscription() {
store.update { StripeStage.CANCELLING }
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
Log.d(TAG, "Beginning cancellation...", true)
store.update { DonationProcessorStage.CANCELLING }
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
onComplete = {
Log.d(TAG, "Cancellation succeeded", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
donationPaymentRepository.scheduleSyncForAccountRecordChange()
store.update { StripeStage.COMPLETE }
stripeRepository.scheduleSyncForAccountRecordChange()
store.update { DonationProcessorStage.COMPLETE }
},
onError = { throwable ->
Log.w(TAG, "Cancellation failed", throwable, true)
store.update { StripeStage.FAILED }
store.update { DonationProcessorStage.FAILED }
}
)
}
fun updateSubscription(request: GatewayRequest) {
store.update { StripeStage.PAYMENT_PIPELINE }
cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
store.update { StripeStage.COMPLETE }
store.update { DonationProcessorStage.COMPLETE }
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
@@ -163,16 +232,18 @@ class StripePaymentInProgressViewModel(
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
store.update { StripeStage.FAILED }
store.update { DonationProcessorStage.FAILED }
}
)
}
class Factory(
private val donationPaymentRepository: DonationPaymentRepository
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StripePaymentInProgressViewModel(donationPaymentRepository)) as T
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
}
}
}

View File

@@ -1,9 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
enum class StripeStage {
INIT,
PAYMENT_PIPELINE,
CANCELLING,
FAILED,
COMPLETE
}

View File

@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
@@ -47,7 +47,7 @@ class ManageDonationsFragment :
ExpiredGiftSheet.Callback {
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__support_technology)))
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
.append(" ")
.append(
SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary)) {
@@ -58,7 +58,7 @@ class ManageDonationsFragment :
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
ManageDonationsViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
}
)
@@ -105,7 +105,7 @@ class ManageDonationsFragment :
noPadTextPref(
title = DSLSettingsText.from(
R.string.DonateToSignalFragment__powered_by,
R.string.DonateToSignalFragment__privacy_over_profit,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -11,7 +11,7 @@ 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.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.recipients.Recipient
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
class ManageDonationsViewModel(
private val subscriptionsRepository: SubscriptionsRepository
private val subscriptionsRepository: MonthlyDonationRepository
) : ViewModel() {
private val store = Store(ManageDonationsState())
@@ -122,7 +122,7 @@ class ManageDonationsViewModel(
}
class Factory(
private val subscriptionsRepository: SubscriptionsRepository
private val subscriptionsRepository: MonthlyDonationRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!

View File

@@ -51,7 +51,6 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
val lottie: LottieAnimationView = view.findViewById(R.id.thanks_bottom_sheet_lottie)
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
val controlText: TextView = view.findViewById(R.id.thanks_bottom_sheet_control_text)
val controlNote: View = view.findViewById(R.id.thanks_bottom_sheet_featured_note)
@@ -63,13 +62,12 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
badgeView.setBadge(args.badge)
badgeName.text = args.badge.name
if (args.badge.isBoost()) {
if (Recipient.self().badges.any { !it.isBoost() }) {
subhead.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_boost_badge_display)
subhead.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_donor_badge)
} else {
subhead.text = SpannableStringBuilder(getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_boost_badge_display))
subhead.text = SpannableStringBuilder(getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_donor_badge))
.append(" ")
.append(getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__you_can_also))
.append(" ")
@@ -84,7 +82,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
)
}
} else {
subhead.text = getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_s_badge_display, args.badge.name)
subhead.text = getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_donor_badge)
}
val otherBadges = Recipient.self().badges.filterNot { it.id == args.badge.id }
@@ -109,7 +107,6 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
}
if (args.isBoost) {
presentBoostCopy()
badgeView.visibility = View.INVISIBLE
lottie.visible = true
lottie.playAnimation()
@@ -124,7 +121,6 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
}
})
} else {
presentSubscriptionCopy()
lottie.visible = false
}
@@ -157,14 +153,6 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
}
}
private fun presentBoostCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
}
private fun presentSubscriptionCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
}
companion object {
private val TAG = Log.tag(ThanksForYourSupportBottomSheetDialogFragment::class.java)
}

View File

@@ -28,7 +28,6 @@ import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.MediaPreviewActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
@@ -69,6 +68,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentD
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
@@ -513,7 +513,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { mediaRecord, isLtr ->
startActivityForResult(
MediaPreviewActivity.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
MediaIntentFactory.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
REQUEST_CODE_RETURN_FROM_MEDIA
)
}

View File

@@ -151,10 +151,11 @@ class DSLConfiguration {
fun primaryButton(
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
val preference = Button.Model.Primary(text, icon, isEnabled, onClick)
children.add(preference)
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.AudioSink
import com.google.android.exoplayer2.audio.DefaultAudioSink
import org.signal.core.util.logging.Log
import java.nio.ByteBuffer
/**
* Certain devices, including the incredibly popular Samsung A51, often fail to create a proper audio sink when switching from "media" mode to "communication" mode.
* It does eventually recover, but it needs to be given ample opportunity to.
* This class wraps the final DefaultAudioSink to provide exactly that functionality.
*/
class RetryableInitAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
enableOffload: Boolean,
val delegate: AudioSink = DefaultAudioSink.Builder()
.setAudioCapabilities(AudioCapabilities.getCapabilities(context))
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setOffloadMode(if (enableOffload) DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else DefaultAudioSink.OFFLOAD_MODE_DISABLED)
.build()
) : AudioSink by delegate {
private var retriesLeft = INITIAL_RETRY_COUNT
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
return try {
val bufferHandled = delegate.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
if (bufferHandled) {
retriesLeft = INITIAL_RETRY_COUNT
}
bufferHandled
} catch (e: AudioSink.InitializationException) {
Log.w(TAG, "Could not handle this buffer due to an initialization exception. $retriesLeft retries remaining.", e)
if (retriesLeft > 0) {
retriesLeft--
false
} else {
throw e
}
}
}
companion object {
private val TAG = Log.tag(RetryableInitAudioSink::class.java)
const val INITIAL_RETRY_COUNT = 5
}
}

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