Compare commits

..

68 Commits

Author SHA1 Message Date
Alex Hart
58a7d3dc08 Bump version to 7.13.2 2024-08-01 16:20:06 -03:00
Alex Hart
3380fa722d Update baseline profile. 2024-08-01 16:06:48 -03:00
Alex Hart
216f57f3ea Update translations and other static files. 2024-08-01 16:03:18 -03:00
Alex Hart
bf4aa85ac3 Fix misplaced call controls when restoring from PIP. 2024-08-01 15:39:02 -03:00
Cody Henthorne
e4966da3ef Fix crash when receiving GSE before state membership updated. 2024-07-31 16:32:46 -04:00
Alex Hart
79c7c2345f Bump version to 7.13.1 2024-07-31 16:35:16 -03:00
Alex Hart
d516037be9 Update baseline profile. 2024-07-31 16:25:15 -03:00
Alex Hart
008b38594d Update translations and other static files. 2024-07-31 16:18:56 -03:00
Cody Henthorne
15b59457f7 Fix expiration clock UI reseting incorrectly on edit. 2024-07-31 14:11:36 -04:00
Cody Henthorne
5ddd1651ee Fix stream reset error handling. 2024-07-30 13:55:08 -04:00
Cody Henthorne
8ca89d2024 Add additional debugging info to group processing lock handling. 2024-07-29 12:37:40 -04:00
Alex Hart
585c8cd863 Fix donation action routing. 2024-07-29 12:24:19 -03:00
Alex Hart
faf6ab233f Remove instance save/restore for the time being. 2024-07-29 10:28:52 -03:00
Nicholas Tinsley
95cbc91bf0 Bump version to 7.13.0 2024-07-26 23:53:15 +02:00
Nicholas Tinsley
9480e23455 Update translations and other static files. 2024-07-26 23:47:28 +02:00
Nicholas Tinsley
727a0f8273 Add additional verification code parser test case. 2024-07-26 23:43:54 +02:00
Nicholas Tinsley
279e55d65f Wire in time remaining for registration lock responses. 2024-07-26 23:43:54 +02:00
Nicholas Tinsley
c36fba2ab7 Handle registration error codes if the user backs out from the enter code screen. 2024-07-26 23:43:54 +02:00
Alex Hart
1d0997379f Add support for several BackupAlert sheet primary actions. 2024-07-26 23:43:54 +02:00
Alex Hart
1a7611d505 Add worker method for determining where to take a user when they press signal backups. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
36846301de Add missing handling for sessions that are already verified. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
b8e81e6677 Add missing registration lock route. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
3d169bffd0 Reserve server-only field in SignalService.proto. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
556a25447e Add logging around registration code autofill. 2024-07-26 23:43:53 +02:00
Alex Hart
b42e48a08a Add ability to long press 'Chats' to get backups subscriber id. 2024-07-26 23:43:53 +02:00
Alex Hart
b1a4e889bc Add support for downgrading backup. 2024-07-26 23:43:53 +02:00
mtang-signal
e6fb01a67b Fix strings. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
6c042f6e47 Add small debugging log statements to ReRegisterWithPinFragment. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
f87ff58701 Convert registration error handling from callbacks to observers. 2024-07-26 23:43:53 +02:00
Cody Henthorne
4fb335de28 Clear drafts when leaving groups. 2024-07-26 23:43:53 +02:00
Alex Hart
725d8dc85d Fixup spinner build. 2024-07-26 23:43:53 +02:00
Alex Hart
e76153b2fd Hide waiting to be let in banner by default. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
0b98901634 Integrate libsignal validator into backup tests. 2024-07-26 23:43:53 +02:00
Nicholas Tinsley
57feb272d2 Clear out any existing registration sessions if the E164 changes. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
7b0badef19 Get shared backup tests working. 2024-07-26 23:43:53 +02:00
Alex Hart
36640edfee Add more error messaging for backups. 2024-07-26 23:43:53 +02:00
Alex Hart
4e07c07ca9 Add backups error string for payment setup errors. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
31ddc5bcc0 Do not crash on invalid sessions. 2024-07-26 23:43:53 +02:00
Alex Hart
c80f459c37 Add CreateBackupBottomSheet. 2024-07-26 23:43:53 +02:00
Alex Hart
2a6dab41f5 Route InAppPaymentType and begin splitting out error messages. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
e6d8e36141 Add new backup testing infrastructure. 2024-07-26 23:43:53 +02:00
Alex Hart
816c9360cd Implement backup receipt generation. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
82c3265be5 Remove now-unnecessary exclusion from apkdiff.py.
Used to need it because baselineprof was non-deterministic, but google
has since fixed this in newer versions of AGP.
2024-07-26 23:43:53 +02:00
Greyson Parrelli
ab03a627da Upgrade libsignal-client to 0.52.5 2024-07-26 23:43:53 +02:00
Greyson Parrelli
81a45ddc09 Fix JobDatabase queue nullability crash. 2024-07-26 23:43:53 +02:00
Cody Henthorne
f1115130b2 Fix incorrect thread used for delete chat history. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
2d557215a0 Don't check for linked devices if not registered. 2024-07-26 23:43:53 +02:00
Alex Hart
cc806a2f79 Add generic payment in progress strings. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
853c934a5a Rotate the crash config feature flag. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
f1ba947a59 Add a "connectivity warning" bottom sheet. 2024-07-26 23:43:53 +02:00
Alex Hart
44b2c62a0e Add finalized strings to strings.xml for backups. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
06d475fb6e Move constraint filtering down into JobStorage to improve perf. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
36dface175 Fix job deletion bug, add performance tests. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
86cf8200b5 Remove cases where all jobs were expected to be in memory. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
973dc72cfa Use a minimal job spec representation in memory. 2024-07-26 23:43:53 +02:00
Greyson Parrelli
eb59afc33c Improve efficiency of sorting jobs in FastJobStorage. 2024-07-26 23:43:53 +02:00
Alex Hart
625ca832b0 Fix bad padding in expired story quotes. 2024-07-26 23:43:53 +02:00
Clark
bc6face873 Fix edit message clearing story reply quote. 2024-07-26 23:43:53 +02:00
Clark
0aeaec8b67 Show edited time instead of original time. 2024-07-22 11:59:12 -04:00
Alex Hart
b70b058925 Fix crash with reused destroyed context. 2024-07-22 11:59:12 -04:00
Greyson Parrelli
e17cf37799 Allow use of the in-app emoji picker when using system emoji. 2024-07-22 11:59:12 -04:00
Alex Hart
330debcf37 Add Snackbar displaying message that user is awaiting entry to an ad hoc call. 2024-07-22 11:59:12 -04:00
mtang-signal
6641cc4806 Update device notification prompt. 2024-07-22 11:59:12 -04:00
Michelle Tang
bd3ab2cc38 Update editing animation. 2024-07-22 11:59:12 -04:00
Clark
fa487e1885 Dont set subscriber data if subscriber is null. 2024-07-22 11:59:12 -04:00
Clark
eb2fc33940 Cap size of group updates. 2024-07-22 11:59:11 -04:00
Alex Hart
c39739bcb4 Add call info treatment for unknown members. 2024-07-22 11:59:11 -04:00
Alex Hart
e7720640d1 Implement landscape calling. 2024-07-22 11:59:11 -04:00
262 changed files with 19646 additions and 9027 deletions

View File

@@ -21,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1439
val canonicalVersionName = "7.12.2"
val canonicalVersionCode = 1442
val canonicalVersionName = "7.13.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -428,7 +428,7 @@ android {
}
onVariants { variant ->
// Include the test-only library on debug builds.
if (variant.buildType != "debug") {
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
}
@@ -598,6 +598,7 @@ dependencies {
androidTestImplementation(testLibs.mockito.kotlin)
androidTestImplementation(testLibs.mockk.android)
androidTestImplementation(testLibs.square.okhttp.mockserver)
androidTestImplementation(testLibs.diff.utils)
androidTestUtil(testLibs.androidx.test.orchestrator)
}

View File

@@ -1,684 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.signal.core.util.Hex
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBlob
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.EmojiSearchTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.io.ByteArrayInputStream
import java.util.Currency
import java.util.UUID
import kotlin.random.Random
typealias DatabaseData = Map<String, List<Map<String, Any?>>>
class BackupTest {
companion object {
val SELF_ACI = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val SELF_PNI = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val SELF_E164 = "+10000000000"
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
val ALICE_ACI = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ALICE_PNI = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
val ALICE_E164 = "+12222222222"
/** Columns that we don't need to check equality of */
private val IGNORED_COLUMNS: Map<String, Set<String>> = mapOf(
RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID),
MessageTable.TABLE_NAME to setOf(MessageTable.FROM_DEVICE_ID)
)
/** Tables we don't need to check equality of */
private val IGNORED_TABLES: Set<String> = setOf(
EmojiSearchTable.TABLE_NAME,
"sqlite_sequence",
"message_fts_data",
"message_fts_idx",
"message_fts_docsize"
)
}
@Before
fun setup() {
SignalStore.account.setE164(SELF_E164)
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
}
@Ignore("Will likely be removed soon")
@Test
fun emptyDatabase() {
backupTest { }
}
@Ignore("Will likely be removed soon")
@Test
fun noteToSelf() {
backupTest {
individualChat(aci = SELF_ACI, givenName = "Note to Self") {
standardMessage(outgoing = true, body = "A")
standardMessage(outgoing = true, body = "B")
standardMessage(outgoing = true, body = "C")
}
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualChat() {
backupTest {
individualChat(aci = ALICE_ACI, givenName = "Alice") {
val m1 = standardMessage(outgoing = true, body = "Outgoing 1")
val m2 = standardMessage(outgoing = false, body = "Incoming 1", read = true)
standardMessage(outgoing = true, body = "Outgoing 2", quotes = m2)
standardMessage(outgoing = false, body = "Incoming 2", quotes = m1, quoteTargetMissing = true, read = false)
standardMessage(outgoing = true, body = "Outgoing 3, with mention", randomMention = true)
standardMessage(outgoing = false, body = "Incoming 3, with style", read = false, randomStyling = true)
remoteDeletedMessage(outgoing = true)
remoteDeletedMessage(outgoing = false)
}
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualRecipients() {
backupTest {
// Comprehensive example
individualRecipient(
aci = ALICE_ACI,
pni = ALICE_PNI,
e164 = ALICE_E164,
givenName = "Alice",
familyName = "Smith",
username = "alice.99",
hidden = false,
registeredState = RecipientTable.RegisteredState.REGISTERED,
profileKey = ProfileKey(Random.nextBytes(32)),
profileSharing = true,
hideStory = false
)
// Trying to get coverage of all the various values
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.NOT_REGISTERED)
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.UNKNOWN)
individualRecipient(pni = PNI.from(UUID.randomUUID()))
individualRecipient(e164 = "+15551234567")
individualRecipient(aci = ACI.from(UUID.randomUUID()), givenName = "Bob")
individualRecipient(aci = ACI.from(UUID.randomUUID()), familyName = "Smith")
individualRecipient(aci = ACI.from(UUID.randomUUID()), profileSharing = false)
individualRecipient(aci = ACI.from(UUID.randomUUID()), hideStory = true)
individualRecipient(aci = ACI.from(UUID.randomUUID()), hidden = true)
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualCallLogs() {
backupTest {
val aliceId = individualRecipient(
aci = ALICE_ACI,
pni = ALICE_PNI,
e164 = ALICE_E164,
givenName = "Alice",
familyName = "Smith",
username = "alice.99",
hidden = false,
registeredState = RecipientTable.RegisteredState.REGISTERED,
profileKey = ProfileKey(Random.nextBytes(32)),
profileSharing = true,
hideStory = false
)
insertOneToOneCallVariations(1, 1, aliceId)
}
}
private fun insertOneToOneCallVariations(callId: Long, timestamp: Long, id: RecipientId): Long {
val directions = arrayOf(CallTable.Direction.INCOMING, CallTable.Direction.OUTGOING)
val callTypes = arrayOf(CallTable.Type.AUDIO_CALL, CallTable.Type.VIDEO_CALL)
val events = arrayOf(
CallTable.Event.MISSED,
CallTable.Event.OUTGOING_RING,
CallTable.Event.ONGOING,
CallTable.Event.ACCEPTED,
CallTable.Event.NOT_ACCEPTED
)
var callTimestamp: Long = timestamp
var currentCallId = callId
for (direction in directions) {
for (event in events) {
for (type in callTypes) {
insertOneToOneCall(callId = currentCallId, callTimestamp, id, type, direction, event)
callTimestamp++
currentCallId++
}
}
}
return currentCallId
}
private fun insertOneToOneCall(callId: Long, timestamp: Long, peer: RecipientId, type: CallTable.Type, direction: CallTable.Direction, event: CallTable.Event) {
val messageType: Long = CallTable.Call.getMessageType(type, direction, event)
SignalDatabase.rawDatabase.withinTransaction {
val recipient = Recipient.resolved(peer)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val outgoing = direction == CallTable.Direction.OUTGOING
val messageValues = contentValuesOf(
MessageTable.FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else peer.serialize(),
MessageTable.FROM_DEVICE_ID to 1,
MessageTable.TO_RECIPIENT_ID to if (outgoing) peer.serialize() else Recipient.self().id.serialize(),
MessageTable.DATE_RECEIVED to timestamp,
MessageTable.DATE_SENT to timestamp,
MessageTable.READ to 1,
MessageTable.TYPE to messageType,
MessageTable.THREAD_ID to threadId
)
val messageId = SignalDatabase.rawDatabase.insert(MessageTable.TABLE_NAME, null, messageValues)
val values = contentValuesOf(
CallTable.CALL_ID to callId,
CallTable.MESSAGE_ID to messageId,
CallTable.PEER to peer.serialize(),
CallTable.TYPE to CallTable.Type.serialize(type),
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
CallTable.EVENT to CallTable.Event.serialize(event),
CallTable.TIMESTAMP to timestamp
)
SignalDatabase.rawDatabase.insert(CallTable.TABLE_NAME, null, values)
SignalDatabase.threads.update(threadId, true)
}
}
@Ignore("Will likely be removed soon")
@Test
fun accountData() {
val context = AppDependencies.application
backupTest(validateKeyValue = true) {
val self = Recipient.self()
// TODO note-to-self archived
// TODO note-to-self unread
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.setE164(SELF_E164)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
SignalDatabase.recipients.setProfileKey(self.id, ProfileKey(Random.nextBytes(32)))
SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker"))
SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/")
InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN))
SignalStore.inAppPayments.setDisplayBadgesOnProfile(false)
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
SignalStore.settings.isLinkPreviewsEnabled = false
SignalStore.settings.isPreferSystemContactPhotos = true
SignalStore.settings.universalExpireTimer = 42
SignalStore.settings.setKeepMutedChatsArchived(true)
SignalStore.story.viewedReceiptsEnabled = false
SignalStore.story.userHasViewedOnboardingStory = true
SignalStore.story.isFeatureDisabled = false
SignalStore.story.userHasBeenNotifiedAboutStories = true
SignalStore.story.userHasSeenGroupStoryEducationSheet = true
SignalStore.emoji.reactions = listOf("a", "b", "c")
TextSecurePreferences.setTypingIndicatorsEnabled(context, false)
TextSecurePreferences.setReadReceiptsEnabled(context, false)
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, true)
}
// Have to check TextSecurePreferences ourselves, since they're not in a database
TextSecurePreferences.isTypingIndicatorsEnabled(context) assertIs false
TextSecurePreferences.isReadReceiptsEnabled(context) assertIs false
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context) assertIs true
}
/**
* Sets up the database, then executes your setup code, then compares snapshots of the database
* before an after an import to ensure that no data was lost/changed.
*
* @param validateKeyValue If true, this will also validate the KeyValueDatabase. You only want to do this if you
* intend on setting most of the values. Otherwise stuff tends to not match since values are lazily written.
*/
private fun backupTest(validateKeyValue: Boolean = false, content: () -> Unit) {
// Under normal circumstances, My Story ends up being the first recipient in the table, and is added automatically.
// This screws with the tests by offsetting all the recipientIds in the initial state.
// Easiest way to get around this is to make the DB a true clean slate by clearing everything.
// (We only really need to clear Recipient/dlists, but doing everything to be consistent.)
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
SignalDatabase.recipients.clearAllDataForBackupRestore()
SignalDatabase.messages.clearAllDataForBackupRestore()
SignalDatabase.threads.clearAllDataForBackupRestore()
// Again, for comparison purposes, because we always import self first, we want to ensure it's the first item
// in the table when we export.
individualRecipient(
aci = SELF_ACI,
pni = SELF_PNI,
e164 = SELF_E164,
profileKey = SELF_PROFILE_KEY,
profileSharing = true
)
content()
val startingMainData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
val startingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
val exported: ByteArray = BackupRepository.export()
BackupRepository.import(length = exported.size.toLong(), inputStreamFactory = { ByteArrayInputStream(exported) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
val endingData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
val endingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
assertDatabaseMatches(startingMainData, endingData)
assertDatabaseMatches(startingKeyValueData, endingKeyValueData)
}
private fun individualChat(aci: ACI, givenName: String, familyName: String? = null, init: IndividualChatCreator.() -> Unit) {
val recipientId = individualRecipient(aci = aci, givenName = givenName, familyName = familyName, profileSharing = true)
val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false)
IndividualChatCreator(SignalDatabase.rawDatabase, recipientId, threadId).init()
SignalDatabase.threads.update(threadId, false)
}
private fun individualRecipient(
aci: ACI? = null,
pni: PNI? = null,
e164: String? = null,
givenName: String? = null,
familyName: String? = null,
username: String? = null,
hidden: Boolean = false,
registeredState: RecipientTable.RegisteredState = RecipientTable.RegisteredState.UNKNOWN,
profileKey: ProfileKey? = null,
profileSharing: Boolean = false,
hideStory: Boolean = false
): RecipientId {
check(aci != null || pni != null || e164 != null)
val recipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164, pniVerified = true, changeSelf = true)
if (givenName != null || familyName != null) {
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts(givenName, familyName))
}
if (username != null) {
SignalDatabase.recipients.setUsername(recipientId, username)
}
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
SignalDatabase.recipients.markRegistered(recipientId, aci ?: pni!!)
} else if (registeredState == RecipientTable.RegisteredState.NOT_REGISTERED) {
SignalDatabase.recipients.markUnregistered(recipientId)
}
if (profileKey != null) {
SignalDatabase.recipients.setProfileKey(recipientId, profileKey)
}
SignalDatabase.recipients.setProfileSharing(recipientId, profileSharing)
SignalDatabase.recipients.setHideStory(recipientId, hideStory)
if (hidden) {
SignalDatabase.recipients.markHidden(recipientId)
}
return recipientId
}
private inner class IndividualChatCreator(
private val db: SQLiteDatabase,
private val recipientId: RecipientId,
private val threadId: Long
) {
fun standardMessage(
outgoing: Boolean,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp,
body: String? = null,
read: Boolean = true,
quotes: Long? = null,
quoteTargetMissing: Boolean = false,
randomMention: Boolean = false,
randomStyling: Boolean = false
): Long {
return db.insertMessage(
from = if (outgoing) Recipient.self().id else recipientId,
to = if (outgoing) recipientId else Recipient.self().id,
outgoing = outgoing,
threadId = threadId,
sentTimestamp = sentTimestamp,
receivedTimestamp = receivedTimestamp,
serverTimestamp = serverTimestamp,
body = body,
read = read,
quotes = quotes,
quoteTargetMissing = quoteTargetMissing,
randomMention = randomMention,
randomStyling = randomStyling
)
}
fun remoteDeletedMessage(
outgoing: Boolean,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp
): Long {
return db.insertMessage(
from = if (outgoing) Recipient.self().id else recipientId,
to = if (outgoing) recipientId else Recipient.self().id,
outgoing = outgoing,
threadId = threadId,
sentTimestamp = sentTimestamp,
receivedTimestamp = receivedTimestamp,
serverTimestamp = serverTimestamp,
remoteDeleted = true
)
}
}
private fun SQLiteDatabase.insertMessage(
from: RecipientId,
to: RecipientId,
outgoing: Boolean,
threadId: Long,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp,
body: String? = null,
read: Boolean = true,
quotes: Long? = null,
quoteTargetMissing: Boolean = false,
randomMention: Boolean = false,
randomStyling: Boolean = false,
remoteDeleted: Boolean = false
): Long {
val type = if (outgoing) {
MessageTypes.BASE_SENT_TYPE
} else {
MessageTypes.BASE_INBOX_TYPE
} or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
val contentValues = ContentValues()
contentValues.put(MessageTable.DATE_SENT, sentTimestamp)
contentValues.put(MessageTable.DATE_RECEIVED, receivedTimestamp)
contentValues.put(MessageTable.FROM_RECIPIENT_ID, from.serialize())
contentValues.put(MessageTable.TO_RECIPIENT_ID, to.serialize())
contentValues.put(MessageTable.THREAD_ID, threadId)
contentValues.put(MessageTable.BODY, body)
contentValues.put(MessageTable.TYPE, type)
contentValues.put(MessageTable.READ, if (read) 1 else 0)
if (!outgoing) {
contentValues.put(MessageTable.DATE_SERVER, serverTimestamp)
}
if (remoteDeleted) {
contentValues.put(MessageTable.REMOTE_DELETED, 1)
return this
.insertInto(MessageTable.TABLE_NAME)
.values(contentValues)
.run()
}
if (quotes != null) {
val quoteDetails = this.getQuoteDetailsFor(quotes)
contentValues.put(MessageTable.QUOTE_ID, if (quoteTargetMissing) MessageTable.QUOTE_TARGET_MISSING_ID else quoteDetails.quotedSentTimestamp)
contentValues.put(MessageTable.QUOTE_AUTHOR, quoteDetails.authorId.serialize())
contentValues.put(MessageTable.QUOTE_BODY, quoteDetails.body)
contentValues.put(MessageTable.QUOTE_BODY_RANGES, quoteDetails.bodyRanges)
contentValues.put(MessageTable.QUOTE_TYPE, quoteDetails.type)
contentValues.put(MessageTable.QUOTE_MISSING, quoteTargetMissing.toInt())
}
if (body != null && (randomMention || randomStyling)) {
val ranges: MutableList<BodyRangeList.BodyRange> = mutableListOf()
if (randomMention) {
ranges += BodyRangeList.BodyRange(
start = 0,
length = Random.nextInt(body.length),
mentionUuid = if (outgoing) Recipient.resolved(to).requireAci().toString() else Recipient.resolved(from).requireAci().toString()
)
}
if (randomStyling) {
ranges += BodyRangeList.BodyRange(
start = 0,
length = Random.nextInt(body.length),
style = BodyRangeList.BodyRange.Style.fromValue(Random.nextInt(BodyRangeList.BodyRange.Style.values().size))
)
}
contentValues.put(MessageTable.MESSAGE_RANGES, BodyRangeList(ranges = ranges).encode())
}
return this
.insertInto(MessageTable.TABLE_NAME)
.values(contentValues)
.run()
}
private fun assertDatabaseMatches(expected: DatabaseData, actual: DatabaseData) {
assert(expected.keys.size == actual.keys.size) { "Mismatched table count! Expected: ${expected.keys} || Actual: ${actual.keys}" }
assert(expected.keys.containsAll(actual.keys)) { "Table names differ! Expected: ${expected.keys} || Actual: ${actual.keys}" }
val tablesToCheck = expected.keys.filter { !IGNORED_TABLES.contains(it) }
for (table in tablesToCheck) {
val expectedTable: List<Map<String, Any?>> = expected[table]!!
val actualTable: List<Map<String, Any?>> = actual[table]!!
assert(expectedTable.size == actualTable.size) { "Mismatched number of rows for table '$table'! Expected: ${expectedTable.size} || Actual: ${actualTable.size}\n $actualTable" }
val expectedFiltered: List<Map<String, Any?>> = expectedTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
val actualFiltered: List<Map<String, Any?>> = actualTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
assert(contentEquals(expectedFiltered, actualFiltered)) { "Data did not match for table '$table'!\n${prettyDiff(expectedFiltered, actualFiltered)}" }
}
}
private fun contentEquals(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): Boolean {
if (expectedRows == actualRows) {
return true
}
assert(expectedRows.size == actualRows.size)
for (i in expectedRows.indices) {
val expectedRow = expectedRows[i]
val actualRow = actualRows[i]
for (key in expectedRow.keys) {
val expectedValue = expectedRow[key]
val actualValue = actualRow[key]
if (!contentEquals(expectedValue, actualValue)) {
return false
}
}
}
return true
}
private fun contentEquals(lhs: Any?, rhs: Any?): Boolean {
return if (lhs is ByteArray && rhs is ByteArray) {
lhs.contentEquals(rhs)
} else {
lhs == rhs
}
}
private fun prettyDiff(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): String {
val builder = StringBuilder()
assert(expectedRows.size == actualRows.size)
for (i in expectedRows.indices) {
val expectedRow = expectedRows[i]
val actualRow = actualRows[i]
var describedRow = false
for (key in expectedRow.keys) {
val expectedValue = expectedRow[key]
val actualValue = actualRow[key]
if (!contentEquals(expectedValue, actualValue)) {
if (!describedRow) {
builder.append("-- ROW ${i + 1}\n")
describedRow = true
}
builder.append("  [$key] Expected: ${expectedValue.prettyPrint()} || Actual: ${actualValue.prettyPrint()} \n")
}
}
if (describedRow) {
builder.append("\n")
builder.append("Expected: $expectedRow\n")
builder.append("Actual: $actualRow\n")
}
}
return builder.toString()
}
private fun Any?.prettyPrint(): String {
return when (this) {
is ByteArray -> "Bytes(${Hex.toString(this)})"
else -> this.toString()
}
}
private fun List<Map<String, Any?>>.withoutExcludedColumns(ignored: Set<String>?): List<Map<String, Any?>> {
return if (ignored != null) {
this.map { row ->
row.filterKeys { !ignored.contains(it) }
}
} else {
this
}
}
private fun SQLiteDatabase.getQuoteDetailsFor(messageId: Long): QuoteDetails {
return this
.select(
MessageTable.DATE_SENT,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.BODY,
MessageTable.MESSAGE_RANGES
)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.ID} = ?", messageId)
.run()
.readToSingleObject { cursor ->
QuoteDetails(
quotedSentTimestamp = cursor.requireLong(MessageTable.DATE_SENT),
authorId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)),
body = cursor.requireString(MessageTable.BODY),
bodyRanges = cursor.requireBlob(MessageTable.MESSAGE_RANGES),
type = QuoteModel.Type.NORMAL.code
)
}!!
}
private fun SQLiteDatabase.readAllContents(): DatabaseData {
return SqlUtil.getAllTables(this).associateWith { table -> this.getAllTableData(table) }
}
private fun SQLiteDatabase.getAllTableData(table: String): List<Map<String, Any?>> {
return this
.select()
.from(table)
.run()
.readToList { cursor ->
val map: MutableMap<String, Any?> = mutableMapOf()
for (i in 0 until cursor.columnCount) {
val column = cursor.getColumnName(i)
when (cursor.getType(i)) {
Cursor.FIELD_TYPE_INTEGER -> map[column] = cursor.getInt(i)
Cursor.FIELD_TYPE_FLOAT -> map[column] = cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> map[column] = cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> map[column] = cursor.getBlob(i)
Cursor.FIELD_TYPE_NULL -> map[column] = null
}
}
map
}
}
private data class QuoteDetails(
val quotedSentTimestamp: Long,
val authorId: RecipientId,
val body: String?,
val bodyRanges: ByteArray?,
val type: Int
)
}

View File

@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import okio.ByteString.Companion.toByteString
import org.junit.Assert
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestName
@@ -78,6 +79,7 @@ import kotlin.time.Duration.Companion.days
* Test the import and export of message backup frames to make sure what
* goes in, comes out.
*/
@Ignore("Deprecated")
class ImportExportTest {
companion object {
/**
@@ -585,7 +587,7 @@ class ImportExportTest {
)
)
import(importData)
val exported = BackupRepository.export()
val exported = BackupRepository.debugExport()
val expected = exportFrames(
*standardFrames,
alexa
@@ -1010,7 +1012,7 @@ class ImportExportTest {
expirationNotStarted
)
import(importData)
val exported = BackupRepository.export()
val exported = BackupRepository.debugExport()
val expected = exportFrames(
*standardFrames,
alice,
@@ -1647,7 +1649,7 @@ class ImportExportTest {
import(originalBackupData)
val generatedBackupData = BackupRepository.export()
val generatedBackupData = BackupRepository.debugExport()
compare(originalBackupData, generatedBackupData)
}

View File

@@ -6,12 +6,17 @@
package org.thoughtcrime.securesms.backup.v2
import androidx.test.platform.app.InstrumentationRegistry
import com.github.difflib.DiffUtils
import com.github.difflib.UnifiedDiffUtils
import junit.framework.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.signal.core.util.Base64
import org.signal.core.util.StreamUtil
import org.signal.libsignal.messagebackup.ComparableBackup
import org.signal.libsignal.messagebackup.MessageBackup
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.kbs.MasterKey
@@ -34,8 +39,10 @@ class ImportExportTestSuite(private val path: String) {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun data(): Collection<Array<String>> {
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)
return testFiles?.map { arrayOf(it) }!!.toList()
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)!!
return testFiles
.map { arrayOf(it) }
.toList()
}
}
@@ -54,13 +61,20 @@ class ImportExportTestSuite(private val path: String) {
val binProtoBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("${TESTS_FOLDER}/$path").use {
StreamUtil.readFully(it)
}
import(binProtoBytes)
val generatedBackupData = BackupRepository.export()
compare(binProtoBytes, generatedBackupData)
val importResult = import(binProtoBytes)
assertTrue(importResult is ImportResult.Success)
val success = importResult as ImportResult.Success
val generatedBackupData = BackupRepository.debugExport(plaintext = true, currentTime = success.backupTime)
assertEquivalent(binProtoBytes, generatedBackupData)
// Validator expects encrypted data, so we have to export again with encryption to validate
val encryptedBackupData = BackupRepository.debugExport(plaintext = false, currentTime = success.backupTime)
assertPassesValidator(encryptedBackupData)
}
private fun import(importData: ByteArray) {
BackupRepository.import(
private fun import(importData: ByteArray): ImportResult {
return BackupRepository.import(
length = importData.size.toLong(),
inputStreamFactory = { ByteArrayInputStream(importData) },
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY),
@@ -68,7 +82,37 @@ class ImportExportTestSuite(private val path: String) {
)
}
// TODO compare with libsignal's library
private fun compare(import: ByteArray, export: ByteArray) {
private fun assertPassesValidator(generatedBackupData: ByteArray) {
BackupRepository.validate(
length = generatedBackupData.size.toLong(),
inputStreamFactory = { ByteArrayInputStream(generatedBackupData) },
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)
)
}
private fun assertEquivalent(import: ByteArray, export: ByteArray) {
val importComparable = ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, import.inputStream(), import.size.toLong())
val exportComparable = ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, export.inputStream(), import.size.toLong())
if (importComparable.unknownFieldMessages.isNotEmpty()) {
throw AssertionError("Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
}
if (exportComparable.unknownFieldMessages.isNotEmpty()) {
throw AssertionError("Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
}
val canonicalImport = importComparable.comparableString
val canonicalExport = exportComparable.comparableString
if (canonicalImport != canonicalExport) {
val importLines = canonicalImport.lines()
val exportLines = canonicalExport.lines()
val patch = DiffUtils.diff(importLines, exportLines)
val diff = UnifiedDiffUtils.generateUnifiedDiff("Import", "Export", importLines, patch, 3).joinToString(separator = "\n")
throw AssertionError("Imported backup does not match exported backup. Diff:\n$diff")
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.EventTimer
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.JobDatabase.Companion.getInstance
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobmanager.JobMigrator
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.concurrent.CountDownLatch
import kotlin.random.Random
@Ignore("This is just for testing performance, not correctness, and they can therefore take a long time. Run them manually when you need to.")
@RunWith(AndroidJUnit4::class)
class JobManagerPerformanceTests {
companion object {
val TAG = Log.tag(JobManagerPerformanceTests::class.java)
}
@Test
fun testPerformance_singleQueue() {
runTest("singleQueue", 2000) { TestJob(queue = "queue") }
}
@Test
fun testPerformance_fourQueues() {
runTest("fourQueues", 2000) { TestJob(queue = "queue-${Random.nextInt(1, 5)}") }
}
@Test
fun testPerformance_noQueues() {
runTest("noQueues", 2000) { TestJob(queue = null) }
}
private fun runTest(name: String, count: Int, jobCreator: () -> TestJob) {
val context = AppDependencies.application
val jobManager = testJobManager(context)
jobManager.beginJobLoop()
val eventTimer = EventTimer()
val latch = CountDownLatch(count)
var seenStart = false
jobManager.addListener({ it.factoryKey == TestJob.KEY }) { _, state ->
if (!seenStart && state == JobTracker.JobState.RUNNING) {
// Adding the jobs can take a while (and runs on a background thread), so we want to reset the timer the first time we see a job run so the first job
// doesn't have a skewed time
eventTimer.reset()
seenStart = true
}
if (state.isComplete) {
eventTimer.emit("job")
latch.countDown()
if (latch.count % 100 == 0L) {
Log.d(TAG, "[$name] Finished ${count - latch.count}/$count jobs")
}
}
}
Log.i(TAG, "[$name] Adding jobs...")
jobManager.addAll((1..count).map { jobCreator() })
Log.i(TAG, "[$name] Waiting for jobs to complete...")
latch.await()
Log.i(TAG, "[$name] Jobs complete!")
Log.i(TAG, eventTimer.stop().summary)
}
private fun testJobManager(context: Application): JobManager {
val config = JobManager.Configuration.Builder()
.setJobFactories(
JobManagerFactories.getJobFactories(context) + mapOf(
TestJob.KEY to TestJob.Factory()
)
)
.setConstraintFactories(JobManagerFactories.getConstraintFactories(context))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(context))
.setJobStorage(FastJobStorage(getInstance(context)))
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context)))
.build()
return JobManager(context, config)
}
private class TestJob(params: Parameters) : Job(params) {
companion object {
const val KEY = "test"
}
constructor(queue: String?) : this(Parameters.Builder().setQueue(queue).build())
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<TestJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): TestJob {
return TestJob(parameters)
}
}
}
}

View File

@@ -61,7 +61,7 @@ object MessageTableTestUtils {
isProfileChange:${type == MessageTypes.PROFILE_CHANGE_TYPE}
isGroupV1MigrationEvent:${type == MessageTypes.GV1_MIGRATION_TYPE}
isChangeNumber:${type == MessageTypes.CHANGE_NUMBER_TYPE}
isBoostRequest:${type == MessageTypes.BOOST_REQUEST_TYPE}
isDonationChannelDonationRequest:${type == MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE}
isThreadMerge:${type == MessageTypes.THREAD_MERGE_TYPE}
isSmsExport:${type == MessageTypes.SMS_EXPORT_TYPE}
isGroupV2LeaveOnly:${type and MessageTypes.GROUP_V2_LEAVE_BITS == MessageTypes.GROUP_V2_LEAVE_BITS}

View File

@@ -121,7 +121,7 @@
android:excludeFromRecents="true"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:configChanges="screenSize|smallestScreenSize|screenLayout"
android:taskAffinity=".calling"
android:resizeableActivity="true"
android:launchMode="singleTask"

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -119,11 +120,18 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
break;
case PROMPT_CONNECTIVITY_WARNING:
ConnectivityWarningBottomSheet.show(getSupportFragmentManager());
break;
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
break;
case PROMPT_DEBUGLOGS_FOR_CRASH:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH);
break;
case PROMPT_DEBUGLOGS_FOR_CONNECTIVITY_WARNING:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CONNECTIVITY_WARNING);
break;
}
}

View File

@@ -22,13 +22,16 @@ import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.media.AudioManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Pair;
import android.util.Rational;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
@@ -39,7 +42,10 @@ import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModelProvider;
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter;
import androidx.window.layout.DisplayFeature;
@@ -58,7 +64,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
@@ -94,8 +100,8 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
@@ -147,7 +153,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
private CallOverflowPopupWindow callOverflowPopupWindow;
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
private DeviceOrientationMonitor deviceOrientationMonitor;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
@@ -176,7 +181,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase);
}
@SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
@SuppressLint({ "MissingInflatedId" })
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
@@ -188,11 +193,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
if (!isLandscapeEnabled) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.webrtc_call_activity);
@@ -201,7 +201,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
initializeResources();
initializeViewModel(isLandscapeEnabled);
initializeViewModel();
initializePictureInPictureParams();
controlsAndInfo = new ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel);
@@ -254,6 +254,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
});
}
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
controlsAndInfo.onStateRestored();
}
@Override
protected void onStart() {
super.onStart();
@@ -307,7 +313,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.i(TAG, "onPause");
super.onPause();
if (!isAskingForPermission && !viewModel.isCallStarting()) {
if (!isAskingForPermission && !viewModel.isCallStarting() && !isChangingConfigurations()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
finish();
@@ -329,7 +335,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
AppDependencies.getSignalCallManager().setEnableVideo(false);
if (!viewModel.isCallStarting()) {
if (!viewModel.isCallStarting() && !isChangingConfigurations()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null) {
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
@@ -343,6 +349,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
protected void onDestroy() {
Log.d(TAG, "onDestroy");
super.onDestroy();
windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer);
EventBus.getDefault().unregister(this);
@@ -484,16 +491,34 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
return state.getLocalParticipant().isHandRaised();
});
getLifecycle().addObserver(participantUpdateWindow);
}
private void initializeViewModel(boolean isLandscapeEnabled) {
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
getLifecycle().addObserver(deviceOrientationMonitor);
private @NonNull Orientation resolveOrientationFromContext() {
int displayOrientation = getResources().getConfiguration().orientation;
int displayRotation = ContextCompat.getDisplayOrDefault(this).getRotation();
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) {
return Orientation.PORTRAIT_BOTTOM_EDGE;
} else if (displayRotation == Surface.ROTATION_270) {
return Orientation.LANDSCAPE_RIGHT_EDGE;
} else {
return Orientation.LANDSCAPE_LEFT_EDGE;
}
}
viewModel = new ViewModelProvider(this, factory).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
private void initializeViewModel() {
final Orientation orientation = resolveOrientationFromContext();
if (orientation == PORTRAIT_BOTTOM_EDGE) {
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface2));
WindowUtil.clearTranslucentNavigationBar(getWindow());
}
LiveData<Pair<Orientation, Boolean>> orientationAndLandscapeEnabled = Transformations.map(new MutableLiveData<>(orientation), o -> Pair.create(o, true));
viewModel = new ViewModelProvider(this).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(true);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, controls -> {
@@ -506,7 +531,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
viewModel.getOrientationAndLandscapeEnabled(),
orientationAndLandscapeEnabled,
viewModel.getEphemeralState(),
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink))
.observe(this, p -> callScreen.updateCallParticipants(p));
@@ -525,8 +550,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
});
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> AppDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
orientationAndLandscapeEnabled.observe(this, pair -> AppDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
addOnPictureInPictureModeChangedListener(info -> {
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
@@ -1262,8 +1286,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
viewModel.setIsLandscapeEnabled(feature.isPresent());
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (feature.isPresent()) {
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
Rect bounds = foldingFeature.getBounds();

View File

@@ -46,7 +46,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFe
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -106,6 +105,20 @@ object BackupRepository {
}
}
@WorkerThread
fun canAccessRemoteBackupSettings(): Boolean {
// TODO [message-backups]
// We need to check whether the user can access remote backup settings.
// 1. Do they have a receipt they need to be able to view?
// 2. Do they have a backup they need to be able to manage?
// The easy thing to do here would actually be to set a ui hint.
return SignalStore.backup.areBackupsEnabled
}
@WorkerThread
fun turnOffAndDeleteBackup() {
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
@@ -181,7 +194,7 @@ object BackupRepository {
}
}
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false) {
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false, currentTime: Long = System.currentTimeMillis()) {
val eventTimer = EventTimer()
val dbSnapshot: SignalDatabase = createSignalDatabaseSnapshot()
val signalStoreSnapshot: SignalStore = createSignalStoreSnapshot()
@@ -198,7 +211,7 @@ object BackupRepository {
)
}
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = SignalStore.backup.backsUpMedia)
val exportState = ExportState(backupTime = currentTime, allowMediaBackup = SignalStore.backup.backsUpMedia)
writer.use {
writer.write(
@@ -249,20 +262,19 @@ object BackupRepository {
}
}
fun export(plaintext: Boolean = false): ByteArray {
/**
* Exports to a blob in memory. Should only be used for testing.
*/
fun debugExport(plaintext: Boolean = false, currentTime: Long = System.currentTimeMillis()): ByteArray {
val outputStream = ByteArrayOutputStream()
export(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext)
export(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext, currentTime = currentTime)
return outputStream.toByteArray()
}
fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult {
val masterKey = SignalStore.svr.getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
}
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
/**
* @return The time the backup was created, or null if the backup could not be read.
*/
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false): ImportResult {
val eventTimer = EventTimer()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
@@ -281,10 +293,10 @@ object BackupRepository {
val header = frameReader.getHeader()
if (header == null) {
Log.e(TAG, "Backup is missing header!")
return
return ImportResult.Failure
} else if (header.version > VERSION) {
Log.e(TAG, "Backup version is newer than we understand: ${header.version}")
return
return ImportResult.Failure
}
// Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction,
@@ -303,9 +315,6 @@ object BackupRepository {
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
// Add back my story after clearing data
DistributionListTables.insertInitialDistributionListAtCreationTime(it)
eventTimer.emit("setup")
val backupState = BackupState(backupKey)
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
@@ -367,6 +376,14 @@ object BackupRepository {
}
Log.d(TAG, "import() ${eventTimer.stop().summary}")
return ImportResult.Success(backupTime = header.backupTimeMs)
}
fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult {
val masterKey = SignalStore.svr.getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
@@ -945,3 +962,8 @@ class BackupMetadata(
val usedSpace: Long,
val mediaCount: Long
)
sealed class ImportResult {
data class Success(val backupTime: Long) : ImportResult()
data object Failure : ImportResult()
}

View File

@@ -158,8 +158,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isChangeNumber(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER)
}
MessageTypes.isBoostRequest(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BOOST_REQUEST)
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
}
MessageTypes.isEndSessionType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION)
@@ -176,6 +176,12 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isPaymentsRequestToActivate(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST)
}
MessageTypes.isUnsupportedMessageType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE)
}
MessageTypes.isReportedSpam(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM)
}
MessageTypes.isExpirationTimerUpdate(record.type) -> {
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn.toInt()))
builder.expiresInMs = 0
@@ -265,7 +271,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0
revisions = emptyList()
sms = record.type.isSmsType()
if (MessageTypes.isCallLog(record.type)) {
if (record.type.isDirectionlessType()) {
directionless = ChatItem.DirectionlessMessageDetails()
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
outgoing = ChatItem.OutgoingMessageDetails(
@@ -999,6 +1005,27 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
return MessageTypes.isOutgoingMessageType(this) || MessageTypes.isInboxType(this)
}
private fun Long.isDirectionlessType(): Boolean {
return MessageTypes.isCallLog(this) ||
MessageTypes.isExpirationTimerUpdate(this) ||
MessageTypes.isThreadMergeType(this) ||
MessageTypes.isSessionSwitchoverType(this) ||
MessageTypes.isProfileChange(this) ||
MessageTypes.isJoinedType(this) ||
MessageTypes.isIdentityUpdate(this) ||
MessageTypes.isIdentityVerified(this) ||
MessageTypes.isIdentityDefault(this) ||
MessageTypes.isReleaseChannelDonationRequest(this) ||
MessageTypes.isChangeNumber(this) ||
MessageTypes.isEndSessionType(this) ||
MessageTypes.isChatSessionRefresh(this) ||
MessageTypes.isBadDecryptType(this) ||
MessageTypes.isPaymentsActivated(this) ||
MessageTypes.isPaymentsRequestToActivate(this) ||
MessageTypes.isUnsupportedMessageType(this) ||
MessageTypes.isReportedSpam(this)
}
private fun String.e164ToLong(): Long? {
val fixed = if (this.startsWith("+")) {
this.substring(1)

View File

@@ -624,13 +624,14 @@ class ChatItemImportInserter(
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST -> MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT or typeWithoutBase
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE or typeWithoutBase
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED or typeWithoutBase
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
SimpleChatUpdate.Type.REPORTED_SPAM -> MessageTypes.SPECIAL_TYPE_REPORTED_SPAM or typeWithoutBase
}
}
updateMessage.expirationTimerChange != null -> {

View File

@@ -6,14 +6,15 @@
package org.thoughtcrime.securesms.backup.v2.database
import okio.ByteString.Companion.toByteString
import org.signal.core.util.CursorUtil
import org.signal.core.util.deleteAll
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
import org.signal.core.util.select
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
@@ -36,7 +37,6 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
val records = readableDatabase
.select()
.from(DistributionListTables.ListTable.TABLE_NAME)
.where(DistributionListTables.ListTable.IS_NOT_DELETED)
.run()
.readToList { cursor ->
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
@@ -48,11 +48,11 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
id = id,
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES),
allowsReplies = cursor.requireBoolean(DistributionListTables.ListTable.ALLOWS_REPLIES),
rawMembers = getRawMembers(id, privacyMode),
members = getMembers(id),
deletedAtTimestamp = 0L,
isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN),
members = getMembersForBackup(id),
deletedAtTimestamp = cursor.requireLong(DistributionListTables.ListTable.DELETION_TIMESTAMP),
isUnknown = cursor.requireBoolean(DistributionListTables.ListTable.IS_UNKNOWN),
privacyMode = privacyMode
)
)
@@ -82,7 +82,37 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
}
}
fun DistributionListTables.getMembersForBackup(id: DistributionListId): List<RecipientId> {
lateinit var privacyMode: DistributionListPrivacyMode
lateinit var rawMembers: List<RecipientId>
readableDatabase.withinTransaction {
privacyMode = getPrivacyMode(id)
rawMembers = getRawMembers(id, privacyMode)
}
return when (privacyMode) {
DistributionListPrivacyMode.ALL -> emptyList()
DistributionListPrivacyMode.ONLY_WITH -> rawMembers
DistributionListPrivacyMode.ALL_EXCEPT -> rawMembers
}
}
fun DistributionListTables.restoreFromBackup(dlistItem: DistributionListItem, backupState: BackupState): RecipientId? {
if (dlistItem.deletionTimestamp != null && dlistItem.deletionTimestamp > 0) {
val dlistId = createList(
name = "",
members = emptyList(),
distributionId = DistributionId.from(UuidUtil.fromByteString(dlistItem.distributionId)),
allowsReplies = false,
deletionTimestamp = dlistItem.deletionTimestamp,
storageId = null,
privacyMode = DistributionListPrivacyMode.ONLY_WITH
)!!
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
}
val dlist = dlistItem.distributionList ?: return null
val members: List<RecipientId> = dlist.memberRecipientIds
.mapNotNull { backupState.backupToLocalRecipientId[it] }
@@ -94,32 +124,22 @@ fun DistributionListTables.restoreFromBackup(dlistItem: DistributionListItem, ba
val distributionId = DistributionId.from(UuidUtil.fromByteString(dlistItem.distributionId))
val privacyMode = dlist.privacyMode.toLocalPrivacyMode()
val dlistId = if (distributionId == DistributionId.MY_STORY) {
setPrivacyMode(DistributionListId.MY_STORY, privacyMode)
members.forEach { addMemberToList(DistributionListId.MY_STORY, privacyMode, it) }
setAllowsReplies(DistributionListId.MY_STORY, dlist.allowReplies)
DistributionListId.MY_STORY
} else {
createList(
name = dlist.name,
members = members,
distributionId = distributionId,
allowsReplies = dlist.allowReplies,
deletionTimestamp = dlistItem.deletionTimestamp ?: 0,
storageId = null,
privacyMode = privacyMode
)!!
}
val dlistId = createList(
name = dlist.name,
members = members,
distributionId = distributionId,
allowsReplies = dlist.allowReplies,
deletionTimestamp = dlistItem.deletionTimestamp ?: 0,
storageId = null,
privacyMode = privacyMode
)!!
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
}
fun DistributionListTables.clearAllDataForBackupRestore() {
writableDatabase
.deleteAll(DistributionListTables.ListTable.TABLE_NAME)
writableDatabase
.deleteAll(DistributionListTables.MembershipTable.TABLE_NAME)
writableDatabase.deleteAll(DistributionListTables.ListTable.TABLE_NAME)
writableDatabase.deleteAll(DistributionListTables.MembershipTable.TABLE_NAME)
}
private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode {

View File

@@ -45,6 +45,7 @@ fun ThreadTable.getThreadsForBackup(): ChatIterator {
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}
FROM ${ThreadTable.TABLE_NAME}
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE ${ThreadTable.ACTIVE} = 1
"""
val cursor = readableDatabase.query(query)

View File

@@ -25,9 +25,9 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ProfileUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.util.Currency
object AccountDataProcessor {
@@ -49,6 +49,15 @@ object AccountDataProcessor {
familyName = selfRecord.signalProfileName.familyName,
avatarUrlPath = selfRecord.signalProfileAvatar ?: "",
username = selfRecord.username,
usernameLink = if (signalStore.accountValues.usernameLink != null) {
AccountData.UsernameLink(
entropy = signalStore.accountValues.usernameLink?.entropy?.toByteString() ?: EMPTY,
serverId = signalStore.accountValues.usernameLink?.serverId?.toByteArray()?.toByteString() ?: EMPTY,
color = signalStore.miscValues.usernameQrCodeColorScheme.toBackupUsernameColor() ?: AccountData.UsernameLink.Color.BLUE
)
} else {
null
},
accountSettings = AccountData.AccountSettings(
storyViewReceiptsEnabled = signalStore.storyValues.viewedReceiptsEnabled,
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
@@ -68,11 +77,7 @@ object AccountDataProcessor {
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding()
),
donationSubscriberData = AccountData.SubscriberData(
subscriberId = donationSubscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
currencyCode = donationSubscriber?.currency?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
manuallyCancelled = signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled()
)
donationSubscriberData = donationSubscriber?.toSubscriberData(signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled())
)
)
)
@@ -180,4 +185,23 @@ object AccountDataProcessor {
else -> UsernameQrCodeColorScheme.Blue
}
}
private fun UsernameQrCodeColorScheme.toBackupUsernameColor(): AccountData.UsernameLink.Color {
return when (this) {
UsernameQrCodeColorScheme.Blue -> AccountData.UsernameLink.Color.BLUE
UsernameQrCodeColorScheme.White -> AccountData.UsernameLink.Color.WHITE
UsernameQrCodeColorScheme.Grey -> AccountData.UsernameLink.Color.GREY
UsernameQrCodeColorScheme.Tan -> AccountData.UsernameLink.Color.OLIVE
UsernameQrCodeColorScheme.Green -> AccountData.UsernameLink.Color.GREEN
UsernameQrCodeColorScheme.Orange -> AccountData.UsernameLink.Color.ORANGE
UsernameQrCodeColorScheme.Pink -> AccountData.UsernameLink.Color.PINK
UsernameQrCodeColorScheme.Purple -> AccountData.UsernameLink.Color.PURPLE
}
}
private fun InAppPaymentSubscriberRecord.toSubscriberData(manuallyCancelled: Boolean): AccountData.SubscriberData {
val subscriberId = subscriberId.bytes.toByteString()
val currencyCode = currency.currencyCode
return AccountData.SubscriberData(subscriberId = subscriberId, currencyCode = currencyCode, manuallyCancelled = manuallyCancelled)
}
}

View File

@@ -32,6 +32,7 @@ object RecipientBackupProcessor {
val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get().toLong()
val releaseChannelId = signalStore.releaseChannelValues.releaseChannelRecipientId
if (releaseChannelId != null) {
state.recipientIds.add(releaseChannelId.toLong())
emitter.emit(
Frame(
recipient = BackupRecipient(

View File

@@ -38,8 +38,12 @@ import org.signal.core.ui.Icons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
/**
* Notifies the user of an issue with their backup.
@@ -73,14 +77,13 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
private fun performPrimaryAction() {
when (backupAlert) {
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> {
// TODO [message-backups] -- Back up now
BackupMessagesJob.enqueue()
startActivity(AppSettingsActivity.remoteBackups(requireContext()))
}
BackupAlert.PAYMENT_PROCESSING -> Unit
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
// TODO [message-backups] -- Download media now
}
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] -- Download media now
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] -- We need to force this to download everything.
AppDependencies.jobManager.add(BackupRestoreMediaJob())
}
BackupAlert.DISK_FULL -> Unit
}

View File

@@ -20,7 +20,7 @@ sealed interface BackupsIconColors {
@get:Composable
val background: Color
object Normal : BackupsIconColors {
data object Normal : BackupsIconColors {
override val foreground: Brush
@Composable get() = remember {
Brush.linearGradient(

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Icons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
/**
* Bottom sheet allowing the user to immediately start a backup or delay.
*/
class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
CreateBackupBottomSheetContent(
onBackupNowClick = {
BackupMessagesJob.enqueue()
startActivity(AppSettingsActivity.remoteBackups(requireContext()))
dismissAllowingStateLoss()
},
onBackupLaterClick = {
dismissAllowingStateLoss()
}
)
}
}
@Composable
private fun CreateBackupBottomSheetContent(
onBackupNowClick: () -> Unit,
onBackupLaterClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
Icons.BrushedForeground(
painter = painterResource(id = R.drawable.symbol_backup_light),
foregroundBrush = BackupsIconColors.Normal.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 11.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Normal.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__create_backup),
style = MaterialTheme.typography.titleLarge
)
Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 8.dp, bottom = 64.dp)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 31.dp)
) {
TextButton(
onClick = onBackupLaterClick,
modifier = Modifier.padding(start = dimensionResource(id = R.dimen.core_ui__gutter))
) {
Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__back_up_later)
)
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onBackupNowClick,
modifier = Modifier.padding(end = dimensionResource(id = R.dimen.core_ui__gutter))
) {
Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__back_up_now)
)
}
}
}
}
@SignalPreview
@Composable
private fun CreateBackupBottomSheetContentPreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
onBackupNowClick = {},
onBackupLaterClick = {}
)
}
}

View File

@@ -1,179 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.restore
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.devicetransfer.moreoptions.MoreTransferOrRestoreOptionsMode
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment which facilitates restoring from a backup during
* registration.
*/
class RestoreFromBackupFragment : ComposeFragment() {
private val navArgs: RestoreFromBackupFragmentArgs by navArgs()
@Composable
override fun FragmentContent() {
RestoreFromBackupContent(
features = persistentListOf(),
onRestoreBackupClick = {
// TODO [message-backups] Restore backup.
},
onCancelClick = {
findNavController()
.popBackStack()
},
onMoreOptionsClick = {
findNavController()
.safeNavigate(RestoreFromBackupFragmentDirections.actionRestoreFromBacakupFragmentToMoreOptions(MoreTransferOrRestoreOptionsMode.SELECTION))
},
cancelable = navArgs.cancelable
)
}
}
@Preview
@Composable
private fun RestoreFromBackupContentPreview() {
Previews.Preview {
RestoreFromBackupContent(
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Your last 30 days of media"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = "All of your text messages"
)
),
onRestoreBackupClick = {},
onCancelClick = {},
onMoreOptionsClick = {},
true
)
}
}
@Composable
private fun RestoreFromBackupContent(
features: ImmutableList<MessageBackupsTypeFeature>,
onRestoreBackupClick: () -> Unit,
onCancelClick: () -> Unit,
onMoreOptionsClick: () -> Unit,
cancelable: Boolean
) {
Column(
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 40.dp, bottom = 24.dp)
) {
Text(
text = "Restore from backup", // TODO [message-backups] Finalized copy.
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
val yourLastBackupText = buildAnnotatedString {
append("Your last backup was made on March 5, 2024 at 9:00am.") // TODO [message-backups] Finalized copy.
append(" ")
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append("Only media sent or received in the past 30 days is included.") // TODO [message-backups] Finalized copy.
}
}
Text(
text = yourLastBackupText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 28.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 18.dp)
) {
Text(
text = "Your backup includes:", // TODO [message-backups] Finalized copy.
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 6.dp)
)
features.forEach {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = it,
iconTint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Restore backup" // TODO [message-backups] Finalized copy.
)
}
if (cancelable) {
TextButton(
onClick = onCancelClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = android.R.string.cancel)
)
}
} else {
TextButton(
onClick = onMoreOptionsClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
)
}
}
}
}

View File

@@ -88,13 +88,13 @@ private fun SheetContent(
}
Text(
text = "Pay $formattedPrice/month to Signal", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__pay_s_per_month, formattedPrice),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 48.dp)
)
Text(
text = "You'll get:", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__youll_get),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 5.dp)
)

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -48,7 +49,7 @@ fun MessageBackupsEducationScreen(
Scaffolds.Settings(
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
title = "Chat backups" // TODO [message-backups] Finalized copy
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups)
) {
Column(
modifier = Modifier
@@ -74,7 +75,7 @@ fun MessageBackupsEducationScreen(
item {
Text(
text = "Chat Backups", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 15.dp)
)
@@ -82,7 +83,7 @@ fun MessageBackupsEducationScreen(
item {
Text(
text = "Back up your messages and media and using Signals secure, end-to-end encrypted storage service. Never lose a message when you get a new phone or reinstall Signal.", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__backup_your_messages_and_media),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -97,17 +98,17 @@ fun MessageBackupsEducationScreen(
) {
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_lock_compact_20),
text = "End-to-end Encrypted" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__end_to_end_encrypted)
)
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_check_square_compact_20),
text = "Optional, always" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__optional_always)
)
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_trash_compact_20),
text = "Delete your backup anytime" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__delete_your_backup_anytime)
)
}
}
@@ -118,7 +119,7 @@ fun MessageBackupsEducationScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Enable backups" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__enable_backups)
)
}
@@ -129,7 +130,7 @@ fun MessageBackupsEducationScreen(
.padding(bottom = 16.dp)
) {
Text(
text = "Learn more" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsEducationScreen__learn_more)
)
}
}

View File

@@ -82,7 +82,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onGeneratePinClick = {},
onCreatePinClick = {},
onUseCurrentPinClick = viewModel::goToNextScreen,
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
@@ -243,6 +243,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
}
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
viewModel.onCancellationComplete()
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}

View File

@@ -45,6 +45,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
)
private val internalPinState = mutableStateOf("")
private var isDowngrading = false
val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
val pinState: State<String> = internalPinState
@@ -129,6 +130,15 @@ class MessageBackupsFlowViewModel : ViewModel() {
internalStateFlow.update { it.copy(selectedMessageBackupTier = messageBackupTier) }
}
fun onCancellationComplete() {
if (isDowngrading) {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
// TODO [message-backups] -- Trigger backup now?
}
}
private fun validatePinAndUpdateState(pin: String): MessageBackupsScreen {
val pinHash = SignalStore.svr.localPinHash
@@ -141,14 +151,18 @@ class MessageBackupsFlowViewModel : ViewModel() {
}
private fun validateTypeAndUpdateState(tier: MessageBackupTier): MessageBackupsScreen {
// TODO [message-backups] - Does anything need to be kicked off?
return when (tier) {
MessageBackupTier.FREE -> {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
isDowngrading = true
MessageBackupsScreen.PROCESS_CANCELLATION
} else {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
MessageBackupsScreen.COMPLETED
// TODO [message-backups] -- Trigger backup now?
MessageBackupsScreen.COMPLETED
}
}
MessageBackupTier.PAID -> MessageBackupsScreen.CHECKOUT_SHEET
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -68,7 +69,7 @@ fun MessageBackupsPinConfirmationScreen(
) {
item {
Text(
text = "Enter your PIN", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_pin),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
@@ -76,7 +77,7 @@ fun MessageBackupsPinConfirmationScreen(
item {
Text(
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
@@ -84,7 +85,6 @@ fun MessageBackupsPinConfirmationScreen(
}
item {
// TODO [message-backups] Confirm default focus state
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
@@ -136,7 +136,7 @@ fun MessageBackupsPinConfirmationScreen(
onClick = onNextClick
) {
Text(
text = "Next" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__next)
)
}
}
@@ -198,7 +198,7 @@ private fun PinKeyboardTypeToggle(
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Switch keyboard" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__switch_keyboard)
)
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -35,12 +36,12 @@ import org.thoughtcrime.securesms.R
@Composable
fun MessageBackupsPinEducationScreen(
onNavigationClick: () -> Unit,
onGeneratePinClick: () -> Unit,
onCreatePinClick: () -> Unit,
onUseCurrentPinClick: () -> Unit,
recommendedPinSize: Int
) {
Scaffolds.Settings(
title = "Backup type", // TODO [message-backups] Finalized copy
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
@@ -68,7 +69,7 @@ fun MessageBackupsPinEducationScreen(
item {
Text(
text = "PINs protect your backup", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__pins_protect_your_backup),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
@@ -76,7 +77,7 @@ fun MessageBackupsPinEducationScreen(
item {
Text(
text = "Your Signal PIN lets you restore your backup when you re-install Signal. For increased security, we recommend updating to a new $recommendedPinSize-digit PIN.", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__your_signal_pin_lets_you),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
@@ -86,7 +87,7 @@ fun MessageBackupsPinEducationScreen(
item {
Text(
text = "If you forget your PIN, you will not be able to restore your backup. You can change your PIN at any time in settings.", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__if_you_forget_your_pin),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
@@ -100,18 +101,18 @@ fun MessageBackupsPinEducationScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__use_current_signal_pin)
)
}
TextButton(
onClick = onGeneratePinClick,
onClick = onCreatePinClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__create_new_pin)
)
}
}
@@ -124,7 +125,7 @@ private fun MessageBackupsPinScreenPreview() {
Previews.Preview {
MessageBackupsPinEducationScreen(
onNavigationClick = {},
onGeneratePinClick = {},
onCreatePinClick = {},
onUseCurrentPinClick = {},
recommendedPinSize = 16
)

View File

@@ -98,24 +98,26 @@ fun MessageBackupsTypeSelectionScreen(
item {
Text(
text = "Choose your backup type", // TODO [message-backups] Finalized copy
text = stringResource(id = R.string.MessagesBackupsTypeSelectionScreen__choose_your_backup_plan),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
// TODO [message-backups] Finalized copy
val primaryColor = MaterialTheme.colorScheme.primary
val readMoreString = buildAnnotatedString {
append("All backups are end-to-end encrypted. Signal is a non-profit—paying for backups helps support our mission. ")
append(stringResource(id = R.string.MessageBackupsTypeSelectionScreen__all_backups_are_end_to_end_encrypted))
val readMore = stringResource(id = R.string.MessageBackupsTypeSelectionScreen__read_more)
append(" ")
withAnnotation(tag = "URL", annotation = "read-more") {
withStyle(
style = SpanStyle(
color = primaryColor
)
) {
append("Read more")
append(readMore)
}
}
}
@@ -146,14 +148,14 @@ fun MessageBackupsTypeSelectionScreen(
}
}
val hasSelectedBackupTier = currentBackupTier != null
val hasCurrentBackupTier = currentBackupTier != null
Buttons.LargePrimary(
onClick = onNextClicked,
enabled = selectedBackupTier != null,
enabled = selectedBackupTier != currentBackupTier && hasCurrentBackupTier,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = if (hasSelectedBackupTier) 10.dp else 16.dp)
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
) {
Text(
text = stringResource(
@@ -166,7 +168,7 @@ fun MessageBackupsTypeSelectionScreen(
)
}
if (hasSelectedBackupTier) {
if (hasCurrentBackupTier) {
TextButton(
onClick = onCancelSubscriptionClicked,
modifier = Modifier

View File

@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.badges.Badges
@@ -71,7 +72,7 @@ class MonthlyDonationCanceledViewModel(
return if (declineCode.isKnown()) {
declineCode.mapToErrorStringResource()
} else if (failureCode.isKnown) {
failureCode.mapToErrorStringResource()
failureCode.mapToErrorStringResource(InAppPaymentType.RECURRING_DONATION)
} else {
declineCode.mapToErrorStringResource()
}

View File

@@ -210,18 +210,12 @@ public class ComposeText extends EmojiEditText {
}
public void setMessageSendType(MessageSendType messageSendType) {
final boolean useSystemEmoji = SignalStore.settings().isPreferSystemEmoji();
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
if (isLandscape()) setImeActionLabel(getContext().getString(messageSendType.getComposeHintRes()), EditorInfo.IME_ACTION_SEND);
else setImeActionLabel(null, 0);
if (useSystemEmoji) {
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
}
setImeOptions(imeOptions);
setHint(getContext().getString(messageSendType.getComposeHintRes()));
setInputType(inputType);

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* A bottom sheet that warns the user when they haven't been able to connect to the websocket for some time.
*/
class ConnectivityWarningBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
ConnectivityWarningBottomSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
SignalStore.misc.lastConnectivityWarningTime = System.currentTimeMillis()
}
}
}
@Composable
override fun SheetContent() {
Sheet(
onDismiss = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun Sheet(onDismiss: () -> Unit = {}) {
return Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().wrapContentSize(Alignment.Center)
) {
BottomSheets.Handle()
Icon(
painterResource(id = R.drawable.ic_connectivity_warning),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.padding(top = 32.dp, bottom = 8.dp)
)
Text(
text = stringResource(id = R.string.ConnectivityWarningBottomSheet_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
Text(
text = stringResource(id = R.string.ConnectivityWarningBottomSheet_body),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp)
)
Row(
modifier = Modifier.padding(top = 60.dp, bottom = 24.dp, start = 24.dp, end = 24.dp)
) {
Buttons.MediumTonal(
onClick = onDismiss,
modifier = Modifier.padding(end = 12.dp)
) {
Text(stringResource(id = R.string.ConnectivityWarningBottomSheet_dismiss_button))
}
}
}
}
@SignalPreview
@Composable
private fun ConnectivityWarningSheetPreview() {
Previews.BottomSheetPreview {
Sheet()
}
}

View File

@@ -321,11 +321,6 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), ((MmsMessageRecord) messageRecord).getScheduledDate()));
} else {
long timestamp = messageRecord.getTimestamp();
if (messageRecord.isEditMessage()) {
if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
timestamp = messageRecord.getDateSent();
}
}
FormattedDate date = DateUtils.getDatelessRelativeTimeSpanFormattedDate(getContext(), locale, timestamp);
String dateLabel = date.getValue();
if (displayMode != ConversationItemDisplayMode.Detailed.INSTANCE && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {

View File

@@ -15,7 +15,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import org.signal.core.util.ResourceUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
@@ -50,6 +49,7 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
when (purpose) {
Purpose.NOTIFICATIONS -> SignalStore.uiHints.lastNotificationLogsPrompt = System.currentTimeMillis()
Purpose.CRASH -> SignalStore.uiHints.lastCrashPrompt = System.currentTimeMillis()
Purpose.CONNECTIVITY_WARNING -> SignalStore.misc.lastConnectivityWarningTime = System.currentTimeMillis()
}
}
}
@@ -85,6 +85,9 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
Purpose.CRASH -> {
binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title_crash)
}
Purpose.CONNECTIVITY_WARNING -> {
binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title_connectivity_warning)
}
}
binding.submit.setOnClickListener {
@@ -137,8 +140,9 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
}
val category = when (purpose) {
Purpose.NOTIFICATIONS -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category)
Purpose.CRASH -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__crash_category)
Purpose.NOTIFICATIONS -> "Slow notifications"
Purpose.CRASH -> "Crash"
Purpose.CONNECTIVITY_WARNING -> "Connectivity"
}
return SupportEmailUtil.generateSupportEmailBody(
@@ -177,17 +181,12 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
enum class Purpose(val serialized: Int) {
NOTIFICATIONS(1),
CRASH(2);
CRASH(2),
CONNECTIVITY_WARNING(3);
companion object {
fun deserialize(serialized: Int): Purpose {
for (value in values()) {
if (value.serialized == serialized) {
return value
}
}
throw IllegalArgumentException("Invalid value: $serialized")
return entries.firstOrNull { it.serialized == serialized } ?: throw IllegalArgumentException("Invalid value: $serialized")
}
}
}

View File

@@ -63,7 +63,7 @@ class DeviceSpecificNotificationBottomSheet : ComposeBottomSheetDialogFragment()
}
@Composable
fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Unit = {}) {
private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Unit = {}) {
return Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().wrapContentSize(Alignment.Center)
@@ -111,7 +111,7 @@ fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Unit = {})
@SignalPreview
@Composable
fun DeviceSpecificSheetPreview() {
private fun DeviceSpecificSheetPreview() {
Previews.BottomSheetPreview {
DeviceSpecificSheet()
}

View File

@@ -1,8 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.util.AttributeSet;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
@@ -26,9 +24,14 @@ public class HidingLinearLayout extends LinearLayout {
super(context, attrs, defStyleAttr);
}
public void hide() {
public void hide(boolean shouldAnimate) {
if (!isEnabled() || getVisibility() == GONE) return;
if (!shouldAnimate) {
setVisibility(GONE);
return;
}
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f));
animation.addAnimation(new AlphaAnimation(1, 0));

View File

@@ -174,13 +174,8 @@ public class InputPanel extends ConstraintLayout
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
if (SignalStore.settings().isPreferSystemEmoji()) {
mediaKeyboard.setVisibility(View.GONE);
emojiVisible = false;
} else {
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
}
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
quoteDismiss.setOnClickListener(v -> clearQuote());
@@ -425,8 +420,9 @@ public class InputPanel extends ConstraintLayout
}
public void enterEditMessageMode(@NonNull RequestManager requestManager, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft, boolean clearQuote) {
int originalHeight = composeTextContainer.getMeasuredHeight();
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
boolean fromEditMessageMode = inEditMessageMode();
int originalHeight = composeTextContainer.getMeasuredHeight();
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
if (!fromDraft) {
MessageStyler.convertSpoilersToComposeMode(textToEdit);
@@ -442,15 +438,20 @@ public class InputPanel extends ConstraintLayout
}
this.messageToEdit = conversationMessageToEdit.getMessageRecord();
updateEditModeUi();
updateEditModeThumbnail(requestManager);
int maxWidth = composeContainer.getWidth();
if (composeContainer.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams layoutParams = (MarginLayoutParams) composeContainer.getLayoutParams();
maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
int maxWidth = composeContainer.getWidth() - mediaKeyboard.getWidth();
if (!fromEditMessageMode) {
maxWidth -= editMessageCancel.getWidth();
if (editMessageCancel.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams layoutParams = (MarginLayoutParams) editMessageCancel.getLayoutParams();
maxWidth -= layoutParams.leftMargin;
}
}
composeTextContainer.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), MeasureSpec.UNSPECIFIED);
int finalHeight = (inEditMessageMode()) ? composeTextContainer.getMeasuredHeight() : composeTextContainer.getMeasuredHeight() + editMessageTitle.getMeasuredHeight();
int finalHeight = composeTextContainer.getMeasuredHeight();
if (editMessageAnimator != null) {
editMessageAnimator.cancel();
@@ -461,7 +462,6 @@ public class InputPanel extends ConstraintLayout
ViewGroup.LayoutParams params = composeTextContainer.getLayoutParams();
params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
composeTextContainer.setLayoutParams(params);
updateEditModeUi();
}
});
editMessageAnimator.start();

View File

@@ -1,43 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
public class MaxHeightFrameLayout extends FrameLayout {
private final int maxHeight;
public MaxHeightFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0);
a.recycle();
} else {
maxHeight = 0;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight));
}
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
/**
* FrameLayout which allows user to specify maximum dimensions of itself and therefore its children.
*/
public class MaxSizeFrameLayout extends FrameLayout {
private final int maxHeight;
private final int maxWidth;
public MaxSizeFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxSizeFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxSizeFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxSizeFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxSizeFrameLayout_msfl_maxHeight, 0);
maxWidth = a.getDimensionPixelSize(R.styleable.MaxSizeFrameLayout_msfl_maxWidth, 0);
a.recycle();
} else {
maxHeight = 0;
maxWidth = 0;
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int newWidthSpec = updateMeasureSpecWithMaxSize(widthMeasureSpec, maxWidth);
int newHeightSpec = updateMeasureSpecWithMaxSize(heightMeasureSpec, maxHeight);
super.onMeasure(newWidthSpec, newHeightSpec);
}
private int updateMeasureSpecWithMaxSize(int measureSpec, int maxSize) {
if (maxSize <= 0) {
return measureSpec;
}
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
if (mode == MeasureSpec.UNSPECIFIED) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.AT_MOST);
} else if (mode == MeasureSpec.EXACTLY && size > maxSize) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.EXACTLY);
} else if (mode == MeasureSpec.AT_MOST && size > maxSize) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.AT_MOST);
} else {
return measureSpec;
}
}
}

View File

@@ -3,22 +3,15 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.os.BundleCompat;
import org.thoughtcrime.securesms.R;
public class OutlinedThumbnailView extends ThumbnailView {
private static final String STATE_OUTLINE_ENABLED = "state.outline.enabled";
private static final String STATE_ROOT = "state.root";
private CornerMask cornerMask;
private Outliner outliner;
private boolean isOutlineEnabled;
@@ -66,29 +59,6 @@ public class OutlinedThumbnailView extends ThumbnailView {
}
}
@Override
protected @NonNull Parcelable onSaveInstanceState() {
Parcelable root = super.onSaveInstanceState();
Bundle state = new Bundle();
state.putParcelable(STATE_ROOT, root);
state.putBoolean(STATE_OUTLINE_ENABLED, isOutlineEnabled);
return state;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Parcelable root = ((Bundle) state).getParcelable(STATE_ROOT);
this.isOutlineEnabled = ((Bundle) state).getBoolean(STATE_OUTLINE_ENABLED, true);
super.onRestoreInstanceState(root);
} else {
super.onRestoreInstanceState(state);
}
}
public void setOutlineEnabled(boolean isOutlineEnabled) {
this.isOutlineEnabled = isOutlineEnabled;
invalidate();

View File

@@ -1,117 +0,0 @@
package org.thoughtcrime.securesms.components.sensors;
import android.content.ContentResolver;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.util.ServiceUtil;
public final class DeviceOrientationMonitor implements DefaultLifecycleObserver {
private static final float MAGNITUDE_MAXIMUM = 1.5f;
private static final float MAGNITUDE_MINIMUM = 0.75f;
private static final float LANDSCAPE_PITCH_MINIMUM = -0.5f;
private static final float LANDSCAPE_PITCH_MAXIMUM = 0.5f;
private final SensorManager sensorManager;
private final ContentResolver contentResolver;
private final EventListener eventListener = new EventListener();
private final float[] accelerometerReading = new float[3];
private final float[] magnetometerReading = new float[3];
private final float[] rotationMatrix = new float[9];
private final float[] orientationAngles = new float[3];
private final MutableLiveData<Orientation> orientation = new MutableLiveData<>(Orientation.PORTRAIT_BOTTOM_EDGE);
public DeviceOrientationMonitor(@NonNull Context context) {
this.sensorManager = ServiceUtil.getSensorManager(context);
this.contentResolver = context.getContentResolver();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
if (accelerometer != null) {
sensorManager.registerListener(eventListener,
accelerometer,
SensorManager.SENSOR_DELAY_NORMAL,
SensorManager.SENSOR_DELAY_UI);
}
Sensor magneticField = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
if (magneticField != null) {
sensorManager.registerListener(eventListener,
magneticField,
SensorManager.SENSOR_DELAY_NORMAL,
SensorManager.SENSOR_DELAY_UI);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
sensorManager.unregisterListener(eventListener);
}
public LiveData<Orientation> getOrientation() {
return Transformations.distinctUntilChanged(orientation);
}
private void updateOrientationAngles() {
int rotationLocked = Settings.System.getInt(contentResolver, Settings.System.ACCELEROMETER_ROTATION, -1);
if (rotationLocked == 0) {
orientation.setValue(Orientation.PORTRAIT_BOTTOM_EDGE);
return;
}
boolean success = SensorManager.getRotationMatrix(rotationMatrix, null, accelerometerReading, magnetometerReading);
if (!success) {
SensorUtil.getRotationMatrixWithoutMagneticSensorData(rotationMatrix, accelerometerReading);
}
SensorManager.getOrientation(rotationMatrix, orientationAngles);
float pitch = orientationAngles[1];
float roll = orientationAngles[2];
float mag = (float) Math.sqrt(Math.pow(pitch, 2) + Math.pow(roll, 2));
if (mag > MAGNITUDE_MAXIMUM || mag < MAGNITUDE_MINIMUM) {
return;
}
if (pitch > LANDSCAPE_PITCH_MINIMUM && pitch < LANDSCAPE_PITCH_MAXIMUM) {
orientation.setValue(roll > 0 ? Orientation.LANDSCAPE_RIGHT_EDGE : Orientation.LANDSCAPE_LEFT_EDGE);
} else {
orientation.setValue(Orientation.PORTRAIT_BOTTOM_EDGE);
}
}
private final class EventListener implements SensorEventListener {
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
System.arraycopy(event.values, 0, accelerometerReading, 0, accelerometerReading.length);
} else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
System.arraycopy(event.values, 0, magnetometerReading, 0, magnetometerReading.length);
}
updateOrientationAngles();
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
}

View File

@@ -67,6 +67,7 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices()
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
StartLocation.RECOVER_USERNAME -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
StartLocation.REMOTE_BACKUPS -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
}
}
@@ -194,6 +195,9 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
@JvmStatic
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, StartLocation.RECOVER_USERNAME)
@JvmStatic
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.REMOTE_BACKUPS)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
@@ -217,7 +221,8 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
PRIVACY(12),
LINKED_DEVICES(13),
USERNAME_LINK(14),
RECOVER_USERNAME(15);
RECOVER_USERNAME(15),
REMOTE_BACKUPS(16);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -5,8 +5,13 @@ import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
@@ -171,7 +176,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
onLongClick = this@AppSettingsFragment::copyDonorBadgeSubscriberIdToClipboard
)
} else {
externalLinkPref(
@@ -197,6 +202,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
},
onLongClick = this@AppSettingsFragment::copyRemoteBackupsSubscriberIdToClipboard,
isEnabled = state.isRegisteredAndUpToDate()
)
@@ -288,15 +294,38 @@ class AppSettingsFragment : DSLSettingsFragment(
}
}
private fun copySubscriberIdToClipboard(): Boolean {
// TODO [alex] -- db access on main thread!
val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
return if (subscriber == null) {
false
} else {
Toast.makeText(requireContext(), R.string.AppSettingsFragment__copied_subscriber_id_to_clipboard, Toast.LENGTH_LONG).show()
Util.copyToClipboard(requireContext(), subscriber.subscriberId.serialize())
true
private fun copyDonorBadgeSubscriberIdToClipboard(): Boolean {
copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_donor_subscriber_id_to_clipboard
)
return true
}
private fun copyRemoteBackupsSubscriberIdToClipboard(): Boolean {
copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_backups_subscriber_id_to_clipboard
)
return true
}
private fun copySubscriberIdToClipboard(
subscriberType: InAppPaymentSubscriberRecord.Type,
@StringRes toastSuccessStringRes: Int
) {
lifecycleScope.launch {
val subscriber = withContext(Dispatchers.IO) {
InAppPaymentsRepository.getSubscriber(subscriberType)
}
withContext(Dispatchers.Main) {
if (subscriber != null) {
Toast.makeText(requireContext(), toastSuccessStringRes, Toast.LENGTH_LONG).show()
Util.copyToClipboard(requireContext(), subscriber.subscriberId.serialize())
}
}
}
}

View File

@@ -430,6 +430,7 @@ class ChangeNumberViewModel : ViewModel() {
is VerificationCodeRequestResult.RegistrationLocked ->
store.update {
it.copy(
lockedTimeRemaining = result.timeRemaining,
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials
)

View File

@@ -84,12 +84,12 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
sectionHeaderPref(R.string.preferences_chats__backups)
if (RemoteConfig.messageBackups || state.remoteBackupsEnabled) {
if (RemoteConfig.messageBackups || state.canAccessRemoteBackupsSettings) {
clickPref(
title = DSLSettingsText.from("Signal Backups"), // TODO [message-backups] -- Finalized copy
summary = DSLSettingsText.from(if (state.remoteBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
title = DSLSettingsText.from(R.string.RemoteBackupsSettingsFragment__signal_backups),
summary = DSLSettingsText.from(if (state.canAccessRemoteBackupsSettings) R.string.arrays__enabled else R.string.arrays__disabled),
onClick = {
if (state.remoteBackupsEnabled) {
if (state.canAccessRemoteBackupsSettings) {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_remoteBackupsSettingsFragment)
} else {
startActivity(CheckoutFlowActivity.createIntent(requireContext(), InAppPaymentType.RECURRING_BACKUP))

View File

@@ -7,5 +7,5 @@ data class ChatsSettingsState(
val useSystemEmoji: Boolean,
val enterKeySends: Boolean,
val localBackupsEnabled: Boolean,
val remoteBackupsEnabled: Boolean
val canAccessRemoteBackupsSettings: Boolean
)

View File

@@ -2,6 +2,11 @@ package org.thoughtcrime.securesms.components.settings.app.chats
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
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.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
@@ -23,12 +28,23 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
useSystemEmoji = SignalStore.settings.isPreferSystemEmoji,
enterKeySends = SignalStore.settings.isEnterKeySends,
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
remoteBackupsEnabled = SignalStore.backup.areBackupsEnabled
canAccessRemoteBackupsSettings = SignalStore.backup.areBackupsEnabled
)
)
val state: LiveData<ChatsSettingsState> = store.stateLiveData
private val disposable = Single.fromCallable { BackupRepository.canAccessRemoteBackupSettings() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { canAccessRemoteBackupSettings ->
store.update { it.copy(canAccessRemoteBackupsSettings = canAccessRemoteBackupSettings) }
}
override fun onCleared() {
disposable.dispose()
}
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings.isLinkPreviewsEnabled = enabled
@@ -63,9 +79,9 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
val remoteBackupsEnabled = SignalStore.backup.areBackupsEnabled
if (store.state.localBackupsEnabled != backupsEnabled ||
store.state.remoteBackupsEnabled != remoteBackupsEnabled
store.state.canAccessRemoteBackupsSettings != remoteBackupsEnabled
) {
store.update { it.copy(localBackupsEnabled = backupsEnabled, remoteBackupsEnabled = remoteBackupsEnabled) }
store.update { it.copy(localBackupsEnabled = backupsEnabled, canAccessRemoteBackupsSettings = remoteBackupsEnabled) }
}
}
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -37,6 +38,7 @@ import androidx.navigation.navArgument
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
@@ -45,9 +47,10 @@ import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Texts
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.receipts.ReceiptImageRenderer
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import java.math.BigDecimal
@@ -97,18 +100,41 @@ class RemoteBackupsPaymentHistoryFragment : ComposeFragment() {
PaymentHistoryDetails(
record = record,
onNavigationClick = onNavigationClick,
onShareClick = {} // TODO [message-backups] Generate shareable png
onShareClick = this@RemoteBackupsPaymentHistoryFragment::onShareClick
)
if (state.displayProgressDialog) {
Dialogs.IndeterminateProgressDialog()
}
}
}
}
private fun onShareClick(record: InAppPaymentReceiptRecord) {
viewModel.onStartRenderingBitmap()
ReceiptImageRenderer.renderPng(
requireContext(),
viewLifecycleOwner,
record,
getString(R.string.RemoteBackupsPaymentHistoryFragment__text_and_all_media_backup),
object : ReceiptImageRenderer.Callback {
override fun onBitmapRendered() {
viewModel.onEndRenderingBitmap()
}
override fun onStartActivity(intent: Intent) {
startActivity(intent)
}
}
)
}
}
@Composable
private fun PaymentHistoryContent(
state: RemoteBackupsPaymentHistoryState,
onNavigationClick: () -> Unit,
onRecordClick: (DonationReceiptRecord) -> Unit
onRecordClick: (InAppPaymentReceiptRecord) -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__payment_history),
@@ -158,8 +184,8 @@ private fun rememberYear(timestamp: Long): Int {
@Composable
private fun PaymentHistoryRow(
record: DonationReceiptRecord,
onRecordClick: (DonationReceiptRecord) -> Unit
record: InAppPaymentReceiptRecord,
onRecordClick: (InAppPaymentReceiptRecord) -> Unit
) {
val date = remember(record.timestamp) {
DateUtils.formatDateWithYear(Locale.getDefault(), record.timestamp)
@@ -196,9 +222,9 @@ private fun PaymentHistoryRow(
@Composable
private fun PaymentHistoryDetails(
record: DonationReceiptRecord,
record: InAppPaymentReceiptRecord,
onNavigationClick: () -> Unit,
onShareClick: () -> Unit
onShareClick: (InAppPaymentReceiptRecord) -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__payment_details),
@@ -249,7 +275,7 @@ private fun PaymentHistoryDetails(
Spacer(modifier = Modifier.weight(1f))
Buttons.LargePrimary(
onClick = onShareClick,
onClick = { onShareClick(record) },
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(bottom = 24.dp)
@@ -300,12 +326,12 @@ private fun PaymentDetailsContentPreview() {
}
}
private fun testRecord(): DonationReceiptRecord {
return DonationReceiptRecord(
private fun testRecord(): InAppPaymentReceiptRecord {
return InAppPaymentReceiptRecord(
id = 1,
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
timestamp = 1718739691000,
type = DonationReceiptRecord.Type.RECURRING_BACKUP,
type = InAppPaymentReceiptRecord.Type.RECURRING_BACKUP,
subscriptionLevel = 201
)
}

View File

@@ -6,11 +6,11 @@
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
object RemoteBackupsPaymentHistoryRepository {
fun getReceipts(): List<DonationReceiptRecord> {
return SignalDatabase.donationReceipts.getReceipts(DonationReceiptRecord.Type.RECURRING_BACKUP)
fun getReceipts(): List<InAppPaymentReceiptRecord> {
return SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.RECURRING_BACKUP)
}
}

View File

@@ -8,9 +8,10 @@ package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
@Stable
data class RemoteBackupsPaymentHistoryState(
val records: PersistentMap<Long, DonationReceiptRecord> = persistentMapOf()
val records: PersistentMap<Long, InAppPaymentReceiptRecord> = persistentMapOf(),
val displayProgressDialog: Boolean = false
)

View File

@@ -29,4 +29,12 @@ class RemoteBackupsPaymentHistoryViewModel : ViewModel() {
internalStateFlow.update { state -> state.copy(records = receipts.associateBy { it.id }.toPersistentMap()) }
}
}
fun onStartRenderingBitmap() {
internalStateFlow.update { it.copy(displayProgressDialog = true) }
}
fun onEndRenderingBitmap() {
internalStateFlow.update { it.copy(displayProgressDialog = false) }
}
}

View File

@@ -121,7 +121,7 @@ private fun BackupsTypeSettingsContent(
item {
Rows.TextRow(
text = "Change or cancel subscription", // TODO [message-backups] final copy
text = stringResource(id = R.string.BackupsTypeSettingsFragment__change_or_cancel_subscription),
onClick = contentCallbacks::onChangeOrCancelSubscriptionClick
)
}
@@ -154,7 +154,7 @@ private fun BackupsTypeRow(
Column {
Text(text = messageBackupsType.title)
Text(
text = "$formattedAmount/month . Renews $renewal", // TODO [message-backups] final copy
text = stringResource(id = R.string.BackupsTypeSettingsFragment__s_month_renews_s, formattedAmount, renewal),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -57,7 +57,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
disposables += Single.fromCallable { BackupRepository.export(plaintext = plaintext) }
disposables += Single.fromCallable { BackupRepository.debugExport(plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->

View File

@@ -122,7 +122,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
priority = TextSecurePreferences.getNotificationPriority(AppDependencies.application),
troubleshootNotifications = if (calculateSlowNotifications) {
(SlowNotificationHeuristics.isBatteryOptimizationsOn() && SlowNotificationHeuristics.isHavingDelayedNotifications()) ||
SlowNotificationHeuristics.showCondition() == DeviceSpecificNotificationConfig.ShowCondition.ALWAYS
SlowNotificationHeuristics.getDeviceSpecificShowCondition() == DeviceSpecificNotificationConfig.ShowCondition.ALWAYS
} else if (currentState != null) {
currentState.messageNotificationsState.troubleshootNotifications
} else {

View File

@@ -40,7 +40,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
}
fun deleteChatHistory() {
viewModelScope.launch {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.threads.deleteAllConversations()
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
}

View File

@@ -15,7 +15,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.InAppPaymentOneTimeContextJob
@@ -106,16 +106,16 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi
}
return Single.fromCallable {
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
val inAppPaymentReceiptRecord = if (isBoost) {
InAppPaymentReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
} else {
DonationReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
InAppPaymentReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
}
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
val donationTypeLabel = inAppPaymentReceiptRecord.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)
SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord)
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(

View File

@@ -6,6 +6,6 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
sealed class DonateToSignalAction {
data class DisplayCurrencySelectionDialog(val inAppPaymentType: InAppPaymentType, val supportedCurrencies: List<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val inAppPayment: InAppPaymentTable.InAppPayment) : DonateToSignalAction()
object CancelSubscription : DonateToSignalAction()
data object CancelSubscription : DonateToSignalAction()
data class UpdateSubscription(val inAppPayment: InAppPaymentTable.InAppPayment, val isLongRunning: Boolean) : DonateToSignalAction()
}

View File

@@ -171,26 +171,32 @@ class DonateToSignalFragment :
}
is DonateToSignalAction.CancelSubscription -> {
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
null,
InAppPaymentType.RECURRING_DONATION
)
findNavController().safeNavigate(navAction)
}
is DonateToSignalAction.UpdateSubscription -> {
if (action.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) {
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION,
action.inAppPayment,
action.inAppPayment.type
)
findNavController().safeNavigate(navAction)
} else {
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION,
action.inAppPayment,
action.inAppPayment.type
)
findNavController().safeNavigate(navAction)
}
}
}
@@ -507,6 +513,7 @@ class DonateToSignalFragment :
}
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
viewModel.refreshActiveSubscription()
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import androidx.fragment.app.Fragment
@@ -127,6 +128,7 @@ class InAppPaymentCheckoutDelegate(
if (result.action == InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION) {
callback.onSubscriptionCancelled(result.inAppPaymentType)
} else {
fragment.requireActivity().setResult(Activity.RESULT_OK)
callback.onPaymentComplete(result.inAppPayment!!)
}
}

View File

@@ -58,14 +58,22 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
// TODO [message-backups] Copy for this button in backups checkout flow.
binding.continueButton.text = if (args.inAppPayment.type == InAppPaymentType.RECURRING_DONATION) {
getString(
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney()))
binding.continueButton.text = when (args.inAppPayment.type) {
InAppPaymentType.RECURRING_DONATION -> {
getString(
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
InAppPaymentType.RECURRING_BACKUP -> {
getString(
R.string.CreditCardFragment__pay_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
else -> {
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney()))
}
}
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))

View File

@@ -20,6 +20,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
@@ -82,8 +83,8 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
private fun presentUiState(stage: InAppPaymentProcessorStage) {
when (stage) {
InAppPaymentProcessorStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
InAppPaymentProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
InAppPaymentProcessorStage.INIT -> binding.progressCardStatus.text = getProcessingStatus()
InAppPaymentProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.text = getProcessingStatus()
InAppPaymentProcessorStage.FAILED -> {
viewModel.onEndAction()
findNavController().popBackStack()
@@ -120,6 +121,14 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
}
private fun getProcessingStatus(): String {
return if (args.inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
getString(R.string.InAppPaymentInProgressFragment__processing_payment)
} else {
getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
}
private fun oneTimeConfirmationPipeline(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
return routeToOneTimeConfirmation(createPaymentIntentResponse)
}

View File

@@ -19,6 +19,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
@@ -68,9 +69,11 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> {
viewModel.processNewDonation(args.inAppPayment!!, this::handleSecure3dsAction)
}
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.inAppPayment!!)
}
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription(args.inAppPaymentType.requireSubscriberType())
}
@@ -85,8 +88,8 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
private fun presentUiState(stage: InAppPaymentProcessorStage) {
when (stage) {
InAppPaymentProcessorStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
InAppPaymentProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
InAppPaymentProcessorStage.INIT -> binding.progressCardStatus.text = getProcessingStatus()
InAppPaymentProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.text = getProcessingStatus()
InAppPaymentProcessorStage.FAILED -> {
viewModel.onEndAction()
findNavController().popBackStack()
@@ -102,6 +105,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
)
)
}
InAppPaymentProcessorStage.COMPLETE -> {
viewModel.onEndAction()
findNavController().popBackStack()
@@ -117,16 +121,25 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
)
)
}
InAppPaymentProcessorStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
}
}
private fun getProcessingStatus(): String {
return if (args.inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
getString(R.string.InAppPaymentInProgressFragment__processing_payment)
} else {
getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
}
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): 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 { emitter ->

View File

@@ -18,6 +18,7 @@ class DonationErrorParams<V> private constructor(
val positiveAction: ErrorAction<V>?,
val negativeAction: ErrorAction<V>?
) {
class ErrorAction<V>(
@StringRes val label: Int,
val action: () -> V
@@ -31,12 +32,12 @@ class DonationErrorParams<V> private constructor(
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable.method, throwable.declineCode, callback)
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable.method, throwable.failureCode, callback)
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable.code, callback)
is DonationError.PaymentSetupError -> getGenericPaymentSetupErrorParams(context, callback)
is DonationError.BadgeRedemptionError.DonationPending -> getStillProcessingErrorParams(context, callback)
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> getStillProcessingErrorParams(context, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable.method, throwable.declineCode, callback, throwable.source.toInAppPaymentType())
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable.method, throwable.failureCode, throwable.source.toInAppPaymentType(), callback)
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable.code, callback, throwable.source.toInAppPaymentType())
is DonationError.PaymentSetupError -> getGenericPaymentSetupErrorParams(context, callback, throwable.source.toInAppPaymentType())
is DonationError.BadgeRedemptionError.DonationPending -> getStillProcessingErrorParams(context, callback, throwable.source.toInAppPaymentType())
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> getStillProcessingErrorParams(context, callback, throwable.source.toInAppPaymentType())
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> getBadgeCredentialValidationErrorParams(context, callback)
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable.source.toInAppPaymentType(), callback)
else -> getGenericRedemptionError(context, InAppPaymentType.ONE_TIME_DONATION, callback)
@@ -50,31 +51,37 @@ class DonationErrorParams<V> private constructor(
): DonationErrorParams<V> {
return when (inAppPayment.data.error?.type) {
InAppPaymentData.Error.Type.UNKNOWN -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.INVALID_GIFT_RECIPIENT -> getVerificationErrorParams(context, callback)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.INVALID_CURRENCY -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.PAYMENT_SETUP -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.STRIPE_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.INVALID_CURRENCY -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.PAYMENT_SETUP -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.STRIPE_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR -> getStripeDeclinedErrorParams(
context = context,
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
declineCode = StripeDeclineCode.getFromCode(inAppPayment.data.error.data_),
callback = callback
callback = callback,
inAppPaymentType = inAppPayment.type
)
InAppPaymentData.Error.Type.STRIPE_FAILURE -> getStripeFailureCodeErrorParams(
context = context,
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
failureCode = StripeFailureCode.getFromCode(inAppPayment.data.error.data_),
inAppPaymentType = inAppPayment.type,
callback = callback
)
InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback, inAppPayment.type)
InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR -> getPayPalDeclinedErrorParams(
context = context,
payPalDeclineCode = PayPalDeclineCode.KnownCode.fromCode(inAppPayment.data.error.data_!!.toInt())!!,
callback = callback
callback = callback,
inAppPaymentType = inAppPayment.type
)
InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION -> getBadgeCredentialValidationErrorParams(context, callback)
InAppPaymentData.Error.Type.REDEMPTION -> getGenericRedemptionError(context, inAppPayment.type, callback)
@@ -92,8 +99,8 @@ class DonationErrorParams<V> private constructor(
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
title = R.string.DonationsErrors__couldnt_add_badge, // TODO [message-backups] -- This will need a backups-specific string
message = R.string.DonationsErrors__your_badge_could_not, // TODO [message-backups] -- This will need a backups-specific string
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
@@ -112,11 +119,12 @@ class DonationErrorParams<V> private constructor(
private fun <V> getPayPalDeclinedErrorParams(
context: Context,
payPalDeclineCode: PayPalDeclineCode.KnownCode,
callback: Callback<V>
callback: Callback<V>,
inAppPaymentType: InAppPaymentType
): DonationErrorParams<V> {
return when (payPalDeclineCode) {
PayPalDeclineCode.KnownCode.DECLINED -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank_for_more_information_if_this_was_a_paypal)
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
PayPalDeclineCode.KnownCode.DECLINED -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank_for_more_information_if_this_was_a_paypal)
else -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
}
@@ -124,17 +132,18 @@ class DonationErrorParams<V> private constructor(
context: Context,
paymentSourceType: PaymentSourceType.Stripe,
declineCode: StripeDeclineCode,
callback: Callback<V>
callback: Callback<V>,
inAppPaymentType: InAppPaymentType
): DonationErrorParams<V> {
if (!paymentSourceType.hasDeclineCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
return getGenericPaymentSetupErrorParams(context, callback, inAppPaymentType)
}
fun unexpectedDeclinedError(declineCode: StripeDeclineCode, paymentSourceType: PaymentSourceType.Stripe): Nothing {
error("Unexpected declined error: $declineCode during $paymentSourceType processing.")
}
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (paymentSourceType) {
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, InAppPaymentType, Int) -> DonationErrorParams<V> = when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> this::getTryCreditCardAgainParams
PaymentSourceType.Stripe.GooglePay -> this::getGoToGooglePayParams
else -> this::getLearnMoreParams
@@ -145,6 +154,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.APPROVE_WITH_ID -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
@@ -155,6 +165,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
@@ -162,10 +173,11 @@ class DonationErrorParams<V> private constructor(
}
)
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
@@ -176,6 +188,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
@@ -186,6 +199,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
@@ -193,10 +207,11 @@ class DonationErrorParams<V> private constructor(
}
)
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
@@ -207,6 +222,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
@@ -217,6 +233,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
@@ -227,6 +244,7 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
inAppPaymentType,
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
@@ -234,13 +252,13 @@ class DonationErrorParams<V> private constructor(
}
)
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again)
StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, inAppPaymentType, InAppPaymentErrorStrings.getStripeIssuerNotAvailableErrorMessage(inAppPaymentType))
StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_again)
StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_again)
else -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
else -> getLearnMoreParams(context, callback, inAppPaymentType, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
}
@@ -248,40 +266,41 @@ class DonationErrorParams<V> private constructor(
context: Context,
paymentSourceType: PaymentSourceType.Stripe,
failureCode: StripeFailureCode,
inAppPaymentType: InAppPaymentType,
callback: Callback<V>
): DonationErrorParams<V> {
if (!paymentSourceType.hasFailureCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
return getGenericPaymentSetupErrorParams(context, callback, inAppPaymentType)
}
return when (failureCode) {
is StripeFailureCode.Known -> {
val errorText = failureCode.mapToErrorStringResource()
val errorText = failureCode.mapToErrorStringResource(inAppPaymentType)
when (failureCode.code) {
StripeFailureCode.Code.REFER_TO_CUSTOMER -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_DISPUTED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.ACCOUNT_CLOSED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.BANK_ACCOUNT_RESTRICTED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.RECIPIENT_DECEASED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.BRANCH_DOES_NOT_EXIST -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INCORRECT_ACCOUNT_HOLDER_NAME -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INVALID_ACCOUNT_NUMBER -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.REFER_TO_CUSTOMER -> getTryBankTransferAgainParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.DEBIT_DISPUTED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.ACCOUNT_CLOSED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.BANK_ACCOUNT_RESTRICTED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.RECIPIENT_DECEASED -> getLearnMoreParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.BRANCH_DOES_NOT_EXIST -> getTryBankTransferAgainParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.INCORRECT_ACCOUNT_HOLDER_NAME -> getTryBankTransferAgainParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.INVALID_ACCOUNT_NUMBER -> getTryBankTransferAgainParams(context, callback, inAppPaymentType, errorText)
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, inAppPaymentType, errorText)
}
}
is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback)
is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback, inAppPaymentType)
}
}
private fun <V> getStillProcessingErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
private fun <V> getStillProcessingErrorParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
message = InAppPaymentErrorStrings.getStillProcessingErrorMessage(inAppPaymentType),
positiveAction = callback.onOk(context),
negativeAction = null
)
@@ -296,45 +315,45 @@ class DonationErrorParams<V> private constructor(
)
}
private fun <V> getGenericPaymentSetupErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
private fun <V> getGenericPaymentSetupErrorParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = R.string.DonationsErrors__your_payment,
title = InAppPaymentErrorStrings.getGenericErrorProcessingTitle(inAppPaymentType),
message = InAppPaymentErrorStrings.getPaymentSetupErrorMessage(inAppPaymentType),
positiveAction = callback.onOk(context),
negativeAction = null
)
}
private fun <V> getLearnMoreParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
private fun <V> getLearnMoreParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
title = InAppPaymentErrorStrings.getGenericErrorProcessingTitle(inAppPaymentType),
message = message,
positiveAction = callback.onOk(context),
negativeAction = callback.onLearnMore(context)
)
}
private fun <V> getGoToGooglePayParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
private fun <V> getGoToGooglePayParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
title = InAppPaymentErrorStrings.getGenericErrorProcessingTitle(inAppPaymentType),
message = message,
positiveAction = callback.onGoToGooglePay(context),
negativeAction = callback.onCancel(context)
)
}
private fun <V> getTryCreditCardAgainParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
private fun <V> getTryCreditCardAgainParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
title = InAppPaymentErrorStrings.getGenericErrorProcessingTitle(inAppPaymentType),
message = message,
positiveAction = callback.onTryCreditCardAgain(context),
negativeAction = callback.onCancel(context)
)
}
private fun <V> getTryBankTransferAgainParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
private fun <V> getTryBankTransferAgainParams(context: Context, callback: Callback<V>, inAppPaymentType: InAppPaymentType, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
title = InAppPaymentErrorStrings.getGenericErrorProcessingTitle(inAppPaymentType),
message = message,
positiveAction = callback.onTryBankTransferAgain(context),
negativeAction = callback.onCancel(context)

View File

@@ -1,22 +1,23 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import androidx.annotation.StringRes
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
@StringRes
fun StripeFailureCode.mapToErrorStringResource(): Int {
fun StripeFailureCode.mapToErrorStringResource(inAppPaymentType: InAppPaymentType): Int {
return when (this) {
is StripeFailureCode.Known -> when (this.code) {
StripeFailureCode.Code.REFER_TO_CUSTOMER -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> R.string.StripeFailureCode__the_bank_account_provided
StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> R.string.StripeFailureCode__this_payment_was_revoked
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> R.string.StripeFailureCode__this_payment_was_revoked
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> InAppPaymentErrorStrings.getStripeFailureCodeAuthorizationRevokedErrorMessage(inAppPaymentType)
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> InAppPaymentErrorStrings.getStripeFailureCodeAuthorizationRevokedErrorMessage(inAppPaymentType)
StripeFailureCode.Code.ACCOUNT_CLOSED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.BANK_ACCOUNT_RESTRICTED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> R.string.StripeFailureCode__an_error_occurred_while_processing_this_payment
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> InAppPaymentErrorStrings.getStripeFailureCodeDebitAuthorizationNotMatchErrorMessage(inAppPaymentType)
StripeFailureCode.Code.RECIPIENT_DECEASED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.BRANCH_DOES_NOT_EXIST -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.INCORRECT_ACCOUNT_HOLDER_NAME -> R.string.StripeFailureCode__verify_your_bank_details_are_correct

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import androidx.annotation.StringRes
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
/**
* Methods to delineate donation vs backup payment error strings.
*
* The format here should remain that the last word in the method name is that of where
* it is being placed in a given error dialog/notification.
*/
object InAppPaymentErrorStrings {
@StringRes
fun getGenericErrorProcessingTitle(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__error_processing_payment
} else {
R.string.DonationsErrors__error_processing_payment
}
}
@StringRes
fun getPaymentSetupErrorMessage(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__your_payment_couldnt_be_processed
} else {
R.string.DonationsErrors__your_payment
}
}
@StringRes
fun getStillProcessingErrorMessage(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__your_payment_is_still
} else {
R.string.DonationsErrors__your_payment_is_still
}
}
@StringRes
fun getStripeIssuerNotAvailableErrorMessage(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__StripeDeclineCode__try_completing_the_payment_again
} else {
R.string.DeclineCode__try_completing_the_payment_again
}
}
@StringRes
fun getStripeFailureCodeAuthorizationRevokedErrorMessage(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__StripeFailureCode__this_payment_was_revoked
} else {
R.string.StripeFailureCode__this_payment_was_revoked
}
}
@StringRes
fun getStripeFailureCodeDebitAuthorizationNotMatchErrorMessage(inAppPaymentType: InAppPaymentType): Int {
return if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
R.string.InAppPaymentErrors__StripeFailureCode__an_error_occurred_while_processing_this_payment
} else {
R.string.StripeFailureCode__an_error_occurred_while_processing_this_payment
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.view.drawToBitmap
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.DateUtils
import java.io.ByteArrayOutputStream
import java.util.Locale
/**
* Generates a receipt PNG for an in-app payment.
*/
object ReceiptImageRenderer {
private const val DONATION_RECEIPT_WIDTH = 1916
private val TAG = Log.tag(ReceiptImageRenderer::class.java)
fun renderPng(
context: Context,
lifecycleOwner: LifecycleOwner,
record: InAppPaymentReceiptRecord,
subscriptionName: String,
callback: Callback
) {
val today: String = DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), System.currentTimeMillis())
val amount: String = FiatMoneyUtil.format(context.resources, record.amount)
val type: String = when (record.type) {
InAppPaymentReceiptRecord.Type.RECURRING_DONATION, InAppPaymentReceiptRecord.Type.RECURRING_BACKUP -> context.getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, context.getString(R.string.DonationReceiptListFragment__recurring))
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION -> context.getString(R.string.DonationReceiptListFragment__one_time)
InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT -> context.getString(R.string.DonationReceiptListFragment__donation_for_a_friend)
}
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
lifecycleOwner.lifecycleScope.launch {
val bitmapUri: Uri = withContext(Dispatchers.Default) {
val outputStream = ByteArrayOutputStream()
val view = LayoutInflater
.from(context)
.inflate(R.layout.donation_receipt_png, null)
view.findViewById<TextView>(R.id.date).text = today
view.findViewById<TextView>(R.id.amount).text = amount
view.findViewById<TextView>(R.id.donation_type).text = type
view.findViewById<TextView>(R.id.date_paid).text = datePaid
view.measure(View.MeasureSpec.makeMeasureSpec(DONATION_RECEIPT_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = view.drawToBitmap()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream)
BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType("image/png")
.withFileName("Signal-Donation-Receipt.png")
.createForSingleSessionInMemory()
}
withContext(Dispatchers.Main) {
callback.onBitmapRendered()
openShareSheet(context, bitmapUri, callback)
}
}
}
private fun openShareSheet(context: Context, uri: Uri, callback: Callback) {
val mimeType = Intent.normalizeMimeType("image/png")
val shareIntent = ShareCompat.IntentBuilder(context)
.setStream(uri)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
callback.onStartActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No activity existed to share the media.", e)
Toast.makeText(context, R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show()
}
}
interface Callback {
fun onBitmapRendered()
fun onStartActivity(intent: Intent)
}
}

View File

@@ -1,32 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.view.drawToBitmap
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
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.receipts.ReceiptImageRenderer
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import java.io.ByteArrayOutputStream
import java.util.Locale
class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.donation_receipt_detail_fragment) {
@@ -49,77 +37,35 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
sharePngButton.isEnabled = false
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.donationReceiptRecord != null) {
adapter.submitList(getConfiguration(state.donationReceiptRecord, state.subscriptionName).toMappingModelList())
if (state.inAppPaymentReceiptRecord != null) {
adapter.submitList(getConfiguration(state.inAppPaymentReceiptRecord, state.subscriptionName).toMappingModelList())
}
if (state.donationReceiptRecord != null && state.subscriptionName != null) {
if (state.inAppPaymentReceiptRecord != null && state.subscriptionName != null) {
sharePngButton.isEnabled = true
sharePngButton.setOnClickListener {
renderPng(state.donationReceiptRecord, state.subscriptionName)
progressDialog = SignalProgressDialog.show(requireContext())
ReceiptImageRenderer.renderPng(
context = requireContext(),
lifecycleOwner = viewLifecycleOwner,
record = state.inAppPaymentReceiptRecord,
subscriptionName = state.subscriptionName,
callback = object : ReceiptImageRenderer.Callback {
override fun onBitmapRendered() {
progressDialog.dismiss()
}
override fun onStartActivity(intent: Intent) {
startActivity(intent)
}
}
)
}
}
}
}
private fun renderPng(record: DonationReceiptRecord, subscriptionName: String) {
progressDialog = SignalProgressDialog.show(requireContext())
val today: String = DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), System.currentTimeMillis())
val amount: String = FiatMoneyUtil.format(resources, record.amount)
val type: String = when (record.type) {
DonationReceiptRecord.Type.RECURRING_DONATION -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.ONE_TIME_DONATION -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.ONE_TIME_GIFT -> getString(R.string.DonationReceiptListFragment__donation_for_a_friend)
DonationReceiptRecord.Type.RECURRING_BACKUP -> error("Not supported in this fragment")
}
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
SimpleTask.run(viewLifecycleOwner.lifecycle, {
val outputStream = ByteArrayOutputStream()
val view = LayoutInflater
.from(requireContext())
.inflate(R.layout.donation_receipt_png, null)
view.findViewById<TextView>(R.id.date).text = today
view.findViewById<TextView>(R.id.amount).text = amount
view.findViewById<TextView>(R.id.donation_type).text = type
view.findViewById<TextView>(R.id.date_paid).text = datePaid
view.measure(View.MeasureSpec.makeMeasureSpec(DONATION_RECEIPT_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = view.drawToBitmap()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream)
BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType("image/png")
.withFileName("Signal-Donation-Receipt.png")
.createForSingleSessionInMemory()
}, {
progressDialog.dismiss()
openShareSheet(it)
})
}
private fun openShareSheet(uri: Uri) {
val mimeType = Intent.normalizeMimeType("image/png")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setStream(uri)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No activity existed to share the media.", e)
Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show()
}
}
private fun getConfiguration(record: DonationReceiptRecord, subscriptionName: String?): DSLConfiguration {
private fun getConfiguration(record: InAppPaymentReceiptRecord, subscriptionName: String?): DSLConfiguration {
return configure {
customPref(
SplashImage.Model(
@@ -141,10 +87,10 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
title = DSLSettingsText.from(R.string.DonationReceiptDetailsFragment__donation_type),
summary = DSLSettingsText.from(
when (record.type) {
DonationReceiptRecord.Type.RECURRING_DONATION -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.ONE_TIME_DONATION -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.ONE_TIME_GIFT -> getString(R.string.DonationReceiptListFragment__donation_for_a_friend)
DonationReceiptRecord.Type.RECURRING_BACKUP -> error("Not supported in this fragment.")
InAppPaymentReceiptRecord.Type.RECURRING_DONATION -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION -> getString(R.string.DonationReceiptListFragment__one_time)
InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT -> getString(R.string.DonationReceiptListFragment__donation_for_a_friend)
InAppPaymentReceiptRecord.Type.RECURRING_BACKUP -> error("Not supported in this fragment.")
}
)
)
@@ -155,10 +101,4 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
)
}
}
companion object {
private const val DONATION_RECEIPT_WIDTH = 1916
private val TAG = Log.tag(DonationReceiptDetailFragment::class.java)
}
}

View File

@@ -4,7 +4,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import java.util.Locale
@@ -22,8 +22,8 @@ class DonationReceiptDetailRepository {
.subscribeOn(Schedulers.io())
}
fun getDonationReceiptRecord(id: Long): Single<DonationReceiptRecord> {
return Single.fromCallable<DonationReceiptRecord> {
fun getDonationReceiptRecord(id: Long): Single<InAppPaymentReceiptRecord> {
return Single.fromCallable<InAppPaymentReceiptRecord> {
SignalDatabase.donationReceipts.getReceipt(id)!!
}.subscribeOn(Schedulers.io())
}

View File

@@ -1,8 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
data class DonationReceiptDetailState(
val donationReceiptRecord: DonationReceiptRecord? = null,
val inAppPaymentReceiptRecord: InAppPaymentReceiptRecord? = null,
val subscriptionName: String? = null
)

View File

@@ -7,7 +7,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
@@ -16,7 +16,7 @@ class DonationReceiptDetailViewModel(id: Long, private val repository: DonationR
private val store = Store(DonationReceiptDetailState())
private val disposables = CompositeDisposable()
private var networkDisposable: Disposable
private val cachedRecord: Single<DonationReceiptRecord> = repository.getDonationReceiptRecord(id).cache()
private val cachedRecord: Single<InAppPaymentReceiptRecord> = repository.getDonationReceiptRecord(id).cache()
val state: LiveData<DonationReceiptDetailState> = store.stateLiveData
@@ -43,7 +43,7 @@ class DonationReceiptDetailViewModel(id: Long, private val repository: DonationR
disposables.clear()
disposables += cachedRecord.subscribe { record ->
store.update { it.copy(donationReceiptRecord = record) }
store.update { it.copy(inAppPaymentReceiptRecord = record) }
}
disposables += cachedRecord.flatMap {

View File

@@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
data class DonationReceiptBadge(
val type: DonationReceiptRecord.Type,
val type: InAppPaymentReceiptRecord.Type,
val level: Int,
val badge: Badge
)

View File

@@ -5,7 +5,7 @@ import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@@ -21,7 +21,7 @@ object DonationReceiptListItem {
}
class Model(
val record: DonationReceiptRecord,
val record: InAppPaymentReceiptRecord,
val badge: Badge?
) : MappingModel<Model> {
override fun areContentsTheSame(newItem: Model): Boolean = record == newItem.record && badge == newItem.badge
@@ -42,10 +42,10 @@ object DonationReceiptListItem {
dateView.text = DateUtils.formatDate(Locale.getDefault(), model.record.timestamp)
typeView.setText(
when (model.record.type) {
DonationReceiptRecord.Type.RECURRING_DONATION -> R.string.DonationReceiptListFragment__recurring
DonationReceiptRecord.Type.ONE_TIME_DONATION -> R.string.DonationReceiptListFragment__one_time
DonationReceiptRecord.Type.ONE_TIME_GIFT -> R.string.DonationReceiptListFragment__donation_for_a_friend
DonationReceiptRecord.Type.RECURRING_BACKUP -> error("Not supported in this fragment")
InAppPaymentReceiptRecord.Type.RECURRING_DONATION -> R.string.DonationReceiptListFragment__recurring
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION -> R.string.DonationReceiptListFragment__one_time
InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT -> R.string.DonationReceiptListFragment__donation_for_a_friend
InAppPaymentReceiptRecord.Type.RECURRING_BACKUP -> error("Not supported in this fragment")
}
)
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.receipts
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
class DonationReceiptListPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 4
@@ -10,9 +10,9 @@ class DonationReceiptListPageAdapter(fragment: Fragment) : FragmentStateAdapter(
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> DonationReceiptListPageFragment.create(null)
1 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.RECURRING_DONATION)
2 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.ONE_TIME_DONATION)
3 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.ONE_TIME_GIFT)
1 -> DonationReceiptListPageFragment.create(InAppPaymentReceiptRecord.Type.RECURRING_DONATION)
2 -> DonationReceiptListPageFragment.create(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
3 -> DonationReceiptListPageFragment.create(InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT)
else -> error("Unsupported position $position")
}
}

View File

@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.TextPreference
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -30,8 +30,8 @@ class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_
}
)
private val type: DonationReceiptRecord.Type?
get() = requireArguments().getString(ARG_TYPE)?.let { DonationReceiptRecord.Type.fromCode(it) }
private val type: InAppPaymentReceiptRecord.Type?
get() = requireArguments().getString(ARG_TYPE)?.let { InAppPaymentReceiptRecord.Type.fromCode(it) }
private lateinit var emptyStateGroup: Group
@@ -71,10 +71,10 @@ class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_
}
}
private fun getBadgeForRecord(record: DonationReceiptRecord, badges: List<DonationReceiptBadge>): Badge? {
private fun getBadgeForRecord(record: InAppPaymentReceiptRecord, badges: List<DonationReceiptBadge>): Badge? {
return when (record.type) {
DonationReceiptRecord.Type.ONE_TIME_DONATION -> badges.firstOrNull { it.type == DonationReceiptRecord.Type.ONE_TIME_DONATION }?.badge
DonationReceiptRecord.Type.ONE_TIME_GIFT -> badges.firstOrNull { it.type == DonationReceiptRecord.Type.ONE_TIME_GIFT }?.badge
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION -> badges.firstOrNull { it.type == InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION }?.badge
InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT -> badges.firstOrNull { it.type == InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT }?.badge
else -> badges.firstOrNull { it.level == record.subscriptionLevel }?.badge
}
}
@@ -83,7 +83,7 @@ class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_
private const val ARG_TYPE = "arg_type"
fun create(type: DonationReceiptRecord.Type?): Fragment {
fun create(type: InAppPaymentReceiptRecord.Type?): Fragment {
return DonationReceiptListPageFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, type?.code)

View File

@@ -3,10 +3,10 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.receipts
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
class DonationReceiptListPageRepository {
fun getRecords(type: DonationReceiptRecord.Type?): Single<List<DonationReceiptRecord>> {
fun getRecords(type: InAppPaymentReceiptRecord.Type?): Single<List<InAppPaymentReceiptRecord>> {
return Single.fromCallable {
SignalDatabase.donationReceipts.getReceipts(type)
}.subscribeOn(Schedulers.io())

View File

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

View File

@@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.util.livedata.Store
class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, repository: DonationReceiptListPageRepository) : ViewModel() {
class DonationReceiptListPageViewModel(type: InAppPaymentReceiptRecord.Type?, repository: DonationReceiptListPageRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val store = Store(DonationReceiptListPageState())
@@ -31,7 +31,7 @@ class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, reposi
disposables.clear()
}
class Factory(private val type: DonationReceiptRecord.Type?, private val repository: DonationReceiptListPageRepository) : ViewModelProvider.Factory {
class Factory(private val type: InAppPaymentReceiptRecord.Type?, private val repository: DonationReceiptListPageRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptListPageViewModel(type, repository)) as T
}

View File

@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import java.util.Locale
@@ -17,13 +17,13 @@ class DonationReceiptListRepository {
}.map { response ->
if (response.result.isPresent) {
val config = response.result.get()
val boostBadge = DonationReceiptBadge(DonationReceiptRecord.Type.ONE_TIME_DONATION, -1, config.getBoostBadges().first())
val giftBadge = DonationReceiptBadge(DonationReceiptRecord.Type.ONE_TIME_GIFT, -1, config.getGiftBadges().first())
val boostBadge = DonationReceiptBadge(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION, -1, config.getBoostBadges().first())
val giftBadge = DonationReceiptBadge(InAppPaymentReceiptRecord.Type.ONE_TIME_GIFT, -1, config.getGiftBadges().first())
val subBadges = config.getSubscriptionLevels().map {
DonationReceiptBadge(
level = it.key,
badge = Badges.fromServiceBadge(it.value.badge),
type = DonationReceiptRecord.Type.RECURRING_DONATION
type = InAppPaymentReceiptRecord.Type.RECURRING_DONATION
)
}
subBadges + boostBadge + giftBadge

View File

@@ -47,7 +47,7 @@ public class BroadcastVideoSink implements VideoSink {
this.deviceOrientationDegrees = deviceOrientationDegrees;
this.rotateToRightSide = false;
this.forceRotate = forceRotate;
this.rotateWithDevice = rotateWithDevice;
this.rotateWithDevice = false;
}
public @NonNull EglBaseWrapper getLockableEglBase() {

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
@@ -13,6 +15,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.BadgeImageView;
@@ -24,14 +28,15 @@ import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.TimeUnit;
public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
public class CallParticipantsListUpdatePopupWindow extends PopupWindow implements DefaultLifecycleObserver {
private static final long DURATION = TimeUnit.SECONDS.toMillis(2);
private static final long DURATION = TimeUnit.SECONDS.toMillis(10);
private final ViewGroup parent;
private final AvatarImageView avatarImageView;
private final BadgeImageView badgeImageView;
private final TextView descriptionTextView;
private final Handler handler;
private final Set<CallParticipantListUpdate.Wrapper> pendingAdditions = new HashSet<>();
private final Set<CallParticipantListUpdate.Wrapper> pendingRemovals = new HashSet<>();
@@ -47,6 +52,7 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
this.avatarImageView = getContentView().findViewById(R.id.avatar);
this.badgeImageView = getContentView().findViewById(R.id.badge);
this.descriptionTextView = getContentView().findViewById(R.id.description);
this.handler = new Handler(Looper.getMainLooper());
setOnDismissListener(this::showPending);
setAnimationStyle(R.style.PopupAnimation);
@@ -72,6 +78,13 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
}
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
handler.removeCallbacksAndMessages(null);
setOnDismissListener(null);
dismiss();
}
private void showPending() {
if (!pendingAdditions.isEmpty()) {
showAdditions();
@@ -102,7 +115,7 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
showAtLocation(parent, Gravity.TOP | Gravity.START, 0, 0);
measureChild();
update();
getContentView().postDelayed(this::dismiss, DURATION);
handler.postDelayed(this::dismiss, DURATION);
}
private void measureChild() {

View File

@@ -194,7 +194,7 @@ data class CallParticipantsState(
}
companion object {
private const val SMALL_GROUP_MAX = 6
const val SMALL_GROUP_MAX = 6
@JvmField
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)

View File

@@ -78,7 +78,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
}
private fun show() {
if (!enabled) {
if (!enabled || parent.windowToken == null) {
return
}

View File

@@ -31,10 +31,10 @@ final class PictureInPictureExpansionHelper {
private final View selfPip;
private final ViewGroup parent;
private final Point expandedDimensions;
private State state = State.IS_SHRUNKEN;
private Point defaultDimensions;
private Point expandedDimensions;
public PictureInPictureExpansionHelper(@NonNull View selfPip) {
this.selfPip = selfPip;
@@ -62,6 +62,11 @@ final class PictureInPictureExpansionHelper {
defaultDimensions = dimensions;
int x = (dimensions.x > dimensions.y) ? EXPANDED_PIP_HEIGHT_DP : EXPANDED_PIP_WIDTH_DP;
int y = (dimensions.x > dimensions.y) ? EXPANDED_PIP_WIDTH_DP : EXPANDED_PIP_HEIGHT_DP;
expandedDimensions = new Point(ViewUtil.dpToPx(x), ViewUtil.dpToPx(y));
if (isExpandedOrExpanding()) {
return;
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.Manifest;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point;
@@ -79,9 +80,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
private static final long TRANSITION_DURATION_MILLIS = 250;
private WebRtcAudioOutputToggleButton audioToggle;
private AccessibleToggleButton videoToggle;
@@ -142,7 +141,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private final Set<View> topViews = new HashSet<>();
private final Set<View> visibleViewSet = new HashSet<>();
private final Set<View> allTimeVisibleViews = new HashSet<>();
private final Set<View> rotatableControls = new HashSet<>();
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
private WebRtcControls controls = WebRtcControls.NONE;
@@ -360,18 +358,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
return false;
});
rotatableControls.add(overflow);
rotatableControls.add(hangup);
rotatableControls.add(answer);
rotatableControls.add(answerWithoutVideo);
rotatableControls.add(audioToggle);
rotatableControls.add(micToggle);
rotatableControls.add(videoToggle);
rotatableControls.add(cameraDirectionToggle);
rotatableControls.add(decline);
rotatableControls.add(smallLocalAudioIndicator);
rotatableControls.add(ringToggle);
missingPermissionContainer.setVisibility(hasCameraPermission() ? View.GONE : View.VISIBLE);
allowAccessButton.setOnClickListener(v -> {
@@ -379,10 +365,17 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
});
ConstraintLayout aboveControls = findViewById(R.id.call_controls_floating_parent);
SlideUpWithCallControlsBehavior behavior = (SlideUpWithCallControlsBehavior) ((CoordinatorLayout.LayoutParams) aboveControls.getLayoutParams()).getBehavior();
Objects.requireNonNull(behavior).setOnTopOfControlsChangedListener(topOfControls -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(topOfControls);
});
if (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
aboveControls.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(bottom + ViewUtil.getStatusBarHeight(v));
});
} else {
SlideUpWithCallControlsBehavior behavior = (SlideUpWithCallControlsBehavior) ((CoordinatorLayout.LayoutParams) aboveControls.getLayoutParams()).getBehavior();
Objects.requireNonNull(behavior).setOnTopOfControlsChangedListener(topOfControls -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(topOfControls);
});
}
}
@Override
@@ -408,12 +401,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
}
public void rotateControls(int degrees) {
for (View view : rotatableControls) {
view.animate().rotation(degrees);
}
}
public void setControlsListener(@Nullable ControlsListener controlsListener) {
this.controlsListener = controlsListener;
}
@@ -873,12 +860,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
constraintSet.setForceId(false);
constraintSet.clone(this);
constraintSet.connect(R.id.call_screen_participants_parent,
ConstraintSet.BOTTOM,
layoutPositions.participantBottomViewId,
layoutPositions.participantBottomViewEndSide,
ViewUtil.dpToPx(layoutPositions.participantBottomMargin));
constraintSet.connect(R.id.call_screen_reactions_feed,
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
@@ -12,12 +11,10 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -41,7 +38,6 @@ import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -68,9 +64,7 @@ public class WebRtcCallViewModel extends ViewModel {
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
private final LiveData<Integer> groupMemberCount = Transformations.map(groupMembers, List::size);
private final Observable<Boolean> shouldShowSpeakerHint = participantsState.map(this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), m));
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
@@ -95,27 +89,10 @@ public class WebRtcCallViewModel extends ViewModel {
private final WebRtcCallRepository repository = new WebRtcCallRepository(AppDependencies.getApplication());
private WebRtcCallViewModel(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
orientation = deviceOrientationMonitor.getOrientation();
controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled),
Transformations.distinctUntilChanged(orientation),
this::resolveRotation);
public WebRtcCallViewModel() {
groupMembers.observeForever(groupMemberStateUpdater);
}
public LiveData<Integer> getControlsRotation() {
return controlsRotation;
}
public LiveData<Orientation> getOrientation() {
return Transformations.distinctUntilChanged(orientation);
}
public LiveData<Pair<Orientation, Boolean>> getOrientationAndLandscapeEnabled() {
return LiveDataUtil.combineLatest(orientation, isLandscapeEnabled, Pair::new);
}
public LiveData<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
@@ -309,7 +286,8 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcViewModel.getAvailableDevices(),
webRtcViewModel.getRemoteDevicesCount().orElse(0),
webRtcViewModel.getParticipantLimit(),
webRtcViewModel.getRecipient().isCallLink());
webRtcViewModel.getRecipient().isCallLink(),
webRtcViewModel.getRemoteParticipants().size() > CallParticipantsState.SMALL_GROUP_MAX);
pendingParticipants.onNext(webRtcViewModel.getPendingParticipants());
@@ -364,23 +342,6 @@ public class WebRtcCallViewModel extends ViewModel {
ephemeralState.setValue(state);
}
private int resolveRotation(boolean isLandscapeEnabled, @NonNull Orientation orientation) {
if (isLandscapeEnabled) {
return 0;
}
switch (orientation) {
case LANDSCAPE_LEFT_EDGE:
return 90;
case LANDSCAPE_RIGHT_EDGE:
return -90;
case PORTRAIT_BOTTOM_EDGE:
return 0;
default:
throw new AssertionError();
}
}
private boolean containsPlaceholders(@NonNull List<CallParticipant> callParticipants) {
return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID);
}
@@ -396,7 +357,8 @@ public class WebRtcCallViewModel extends ViewModel {
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
long remoteDevicesCount,
@Nullable Long participantLimit,
boolean isCallLink)
boolean isCallLink,
boolean hasParticipantOverflow)
{
final WebRtcControls.CallState callState;
@@ -446,9 +408,11 @@ public class WebRtcCallViewModel extends ViewModel {
groupCallState = (participantLimit == null || remoteDevicesCount < participantLimit) ? WebRtcControls.GroupCallState.CONNECTING
: WebRtcControls.GroupCallState.FULL;
break;
case CONNECTED_AND_PENDING:
groupCallState = WebRtcControls.GroupCallState.PENDING;
break;
case CONNECTED:
case CONNECTED_AND_JOINING:
case CONNECTED_AND_PENDING:
case CONNECTED_AND_JOINED:
groupCallState = WebRtcControls.GroupCallState.CONNECTED;
break;
@@ -468,7 +432,8 @@ public class WebRtcCallViewModel extends ViewModel {
WebRtcControls.FoldableState.flat(),
activeDevice,
availableDevices,
isCallLink));
isCallLink,
hasParticipantOverflow));
}
private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {
@@ -608,18 +573,4 @@ public class WebRtcCallViewModel extends ViewModel {
return recipientIds;
}
}
public static class Factory implements ViewModelProvider.Factory {
private final DeviceOrientationMonitor deviceOrientationMonitor;
public Factory(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
this.deviceOrientationMonitor = deviceOrientationMonitor;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return Objects.requireNonNull(modelClass.cast(new WebRtcCallViewModel(deviceOrientationMonitor)));
}
}
}

View File

@@ -28,6 +28,7 @@ public final class WebRtcControls {
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet(),
false,
false);
private final boolean isRemoteVideoEnabled;
@@ -42,6 +43,7 @@ public final class WebRtcControls {
private final SignalAudioManager.AudioDevice activeDevice;
private final Set<SignalAudioManager.AudioDevice> availableDevices;
private final boolean isCallLink;
private final boolean hasParticipantOverflow;
private WebRtcControls() {
this(false,
@@ -55,6 +57,7 @@ public final class WebRtcControls {
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet(),
false,
false);
}
@@ -69,7 +72,8 @@ public final class WebRtcControls {
@NonNull FoldableState foldableState,
@NonNull SignalAudioManager.AudioDevice activeDevice,
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
boolean isCallLink)
boolean isCallLink,
boolean hasParticipantOverflow)
{
this.isLocalVideoEnabled = isLocalVideoEnabled;
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
@@ -83,6 +87,7 @@ public final class WebRtcControls {
this.activeDevice = activeDevice;
this.availableDevices = availableDevices;
this.isCallLink = isCallLink;
this.hasParticipantOverflow = hasParticipantOverflow;
}
public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) {
@@ -97,7 +102,8 @@ public final class WebRtcControls {
foldableState,
activeDevice,
availableDevices,
isCallLink);
isCallLink,
hasParticipantOverflow);
}
/**
@@ -178,7 +184,7 @@ public final class WebRtcControls {
}
public boolean displayRemoteVideoRecycler() {
return isOngoing();
return isOngoing() && hasParticipantOverflow;
}
public boolean displayAnswerWithoutVideo() {
@@ -221,6 +227,10 @@ public final class WebRtcControls {
return !isInPipMode;
}
public boolean displayWaitingToBeLetIn() {
return !isInPipMode && groupCallState == GroupCallState.PENDING;
}
public @NonNull WebRtcAudioOutput getAudioOutput() {
switch (activeDevice) {
case SPEAKER_PHONE:
@@ -276,7 +286,6 @@ public final class WebRtcControls {
private int displayedButtonCount() {
return (displayAudioToggle() ? 1 : 0) +
(displayCameraToggle() ? 1 : 0) +
(displayVideoToggle() ? 1 : 0) +
(displayMuteAudio() ? 1 : 0) +
(displayRingToggle() ? 1 : 0) +
@@ -306,6 +315,7 @@ public final class WebRtcControls {
RECONNECTING,
CONNECTING,
FULL,
PENDING,
CONNECTED;
boolean isAtLeast(@SuppressWarnings("SameParameterValue") @NonNull GroupCallState other) {

View File

@@ -6,9 +6,12 @@
package org.thoughtcrime.securesms.components.webrtc.controls
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
@@ -29,8 +32,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -46,13 +51,19 @@ import androidx.lifecycle.toLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.LocalExtendedColors
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarImage
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
@@ -259,6 +270,15 @@ private fun CallInfo(
onBlockClicked = onBlock
)
}
if (participantsState.inCallLobby && participantsState.unknownParticipantCount > 0) {
item {
UnknownMembersRow(
unknownMemberCount = participantsState.unknownParticipantCount,
allCallMembersAreUnknown = participantsState.participantsForList.isEmpty()
)
}
}
} else if (participantsState.isGroupCall()) {
items(
items = participantsState.groupMembers,
@@ -516,6 +536,129 @@ private fun GroupMemberRow(
) {}
}
@Composable
private fun UnknownMembersRow(
unknownMemberCount: Int,
allCallMembersAreUnknown: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
when (unknownMemberCount) {
1 -> SingleUnknownAvatar()
2 -> TwoUnknownAvatars()
else -> ThreeUnknownAvatars()
}
val textResId = if (allCallMembersAreUnknown) {
R.plurals.CallInfoView__d_people
} else {
R.plurals.CallInfoView__plus_d_people
}
Text(
text = pluralStringResource(
id = textResId,
count = unknownMemberCount,
unknownMemberCount
),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 24.dp)
)
var displayDialog by remember { mutableStateOf(false) }
Icon(
painter = painterResource(id = R.drawable.symbol_info_24),
contentDescription = stringResource(id = R.string.CallInfoView__more_information),
modifier = Modifier.clickable(onClick = {
displayDialog = true
})
)
if (displayDialog) {
Dialogs.SimpleMessageDialog(
message = stringResource(id = R.string.CallInfoView__before_joining_a_call),
dismiss = stringResource(id = R.string.CallInfoView__got_it),
onDismiss = { displayDialog = false }
)
}
}
}
@Composable
private fun SingleUnknownAvatar() {
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier.size(40.dp)
)
}
@Composable
private fun TwoUnknownAvatars() {
Box(modifier = Modifier.width(40.dp)) {
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier
.size(34.dp)
.align(Alignment.CenterStart)
)
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier
.size(38.dp)
.align(Alignment.CenterEnd)
.border(width = 2.dp, color = LocalExtendedColors.current.colorSurface1, shape = CircleShape)
)
}
}
@Composable
private fun ThreeUnknownAvatars() {
Box(modifier = Modifier.width(40.dp)) {
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier
.size(27.dp)
.align(Alignment.CenterStart)
)
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier
.size(31.dp)
.align(Alignment.Center)
.border(width = 2.dp, color = SignalTheme.colors.colorSurface1, shape = CircleShape)
)
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Resource.Person(AvatarColor.random()),
modifier = Modifier
.size(31.dp)
.align(Alignment.CenterEnd)
.border(width = 2.dp, color = SignalTheme.colors.colorSurface1, shape = CircleShape)
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun UnknownMembersRowPreview() {
Previews.BottomSheetPreview {
Column {
UnknownMembersRow(unknownMemberCount = 1, allCallMembersAreUnknown = true)
UnknownMembersRow(unknownMemberCount = 1, allCallMembersAreUnknown = false)
UnknownMembersRow(unknownMemberCount = 2, allCallMembersAreUnknown = false)
UnknownMembersRow(unknownMemberCount = 3, allCallMembersAreUnknown = false)
}
}
}
private data class ParticipantsState(
val inCallLobby: Boolean = false,
val ringGroup: Boolean = true,
@@ -532,7 +675,9 @@ private data class ParticipantsState(
listOf(localParticipant) + remoteParticipants
} else {
remoteParticipants
}
}.filter { it.recipient.isProfileSharing }
val unknownParticipantCount = remoteParticipants.count { !it.recipient.isProfileSharing }
val participantCountForDisplay: Int = if (participantCount == 0) {
participantsForList.size

View File

@@ -8,9 +8,13 @@ package org.thoughtcrime.securesms.components.webrtc.controls
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.os.Handler
import android.os.Parcelable
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.Px
@@ -32,6 +36,8 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
@@ -40,6 +46,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.parcelize.Parcelize
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -61,70 +68,78 @@ import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
/**
* Brain for rendering the call controls and info within a bottom sheet.
* Brain for rendering the call controls and info within a bottom sheet when we display the activity in portrait mode.
*/
class ControlsAndInfoController(
class ControlsAndInfoController private constructor(
private val webRtcCallActivity: WebRtcCallActivity,
private val webRtcCallView: WebRtcCallView,
private val overflowPopupWindow: CallOverflowPopupWindow,
private val viewModel: WebRtcCallViewModel,
private val controlsAndInfoViewModel: ControlsAndInfoViewModel
) : CallInfoView.Callbacks, Disposable {
private val controlsAndInfoViewModel: ControlsAndInfoViewModel,
private val disposables: CompositeDisposable
) : CallInfoView.Callbacks, Disposable by disposables {
constructor(
webRtcCallActivity: WebRtcCallActivity,
webRtcCallView: WebRtcCallView,
overflowPopupWindow: CallOverflowPopupWindow,
viewModel: WebRtcCallViewModel,
controlsAndInfoViewModel: ControlsAndInfoViewModel
) : this(
webRtcCallActivity,
webRtcCallView,
overflowPopupWindow,
viewModel,
controlsAndInfoViewModel,
CompositeDisposable()
)
companion object {
private val TAG = Log.tag(ControlsAndInfoController::class.java)
private const val CONTROL_TRANSITION_DURATION = 250L
private const val CONTROL_FADE_OUT_START = 0f
private const val CONTROL_FADE_OUT_DONE = 0.23f
private const val INFO_FADE_IN_START = CONTROL_FADE_OUT_DONE
private const val INFO_FADE_IN_DONE = 0.8f
private const val CONTROL_TRANSITION_DURATION = 250L
private val INFO_TRANSLATION_DISTANCE = 24f.dp
private val HIDE_CONTROL_DELAY = 5.seconds.inWholeMilliseconds
}
private val disposables = CompositeDisposable()
private val coordinator: CoordinatorLayout = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
private val callInfoComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
private val frame: FrameLayout = webRtcCallView.findViewById(R.id.call_controls_info_parent)
private val behavior = BottomSheetBehavior.from(frame)
private val raiseHandComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
private val aboveControlsGuideline: Guideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline)
private val toggleCameraDirectionView: View = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle)
private val callControls: ConstraintLayout = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
private val isLandscape = webRtcCallActivity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
private val waitingToBeLetInProgressDrawable = IndeterminateDrawable.createCircularDrawable(
webRtcCallActivity,
CircularProgressIndicatorSpec(webRtcCallActivity, null).apply {
indicatorSize = 20.dp
indicatorInset = 0.dp
trackThickness = 2.dp
trackCornerRadius = 1.dp
indicatorColors = intArrayOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorOnBackground))
trackColor = Color.TRANSPARENT
}
)
private val waitingToBeLetIn: TextView = webRtcCallView.findViewById<TextView>(R.id.call_controls_waiting_to_be_let_in).apply {
setCompoundDrawablesRelativeWithIntrinsicBounds(waitingToBeLetInProgressDrawable, null, null, null)
}
private val coordinator: CoordinatorLayout
private val frame: FrameLayout
private val behavior: BottomSheetBehavior<View>
private val callInfoComposeView: ComposeView
private val raiseHandComposeView: ComposeView
private val callControls: ConstraintLayout
private val aboveControlsGuideline: Guideline
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
private val toggleCameraDirectionView: View
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val handler: Handler?
get() = webRtcCallView.handler
private var previousCallControlHeightData = HeightData()
private var controlPeakHeight = 0
private var controlState: WebRtcControls = WebRtcControls.NONE
init {
val infoTranslationDistance = 24f.dp
coordinator = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
frame = webRtcCallView.findViewById(R.id.call_controls_info_parent)
behavior = BottomSheetBehavior.from(frame)
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
raiseHandComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
aboveControlsGuideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline)
toggleCameraDirectionView = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle)
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop))
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = infoTranslationDistance
raiseHandComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
@@ -132,20 +147,6 @@ class ControlsAndInfoController(
}
}
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_HIDDEN
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
coordinator.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
webRtcCallView.post { onControlTopChanged() }
}
@@ -154,43 +155,22 @@ class ControlsAndInfoController(
onControlTopChanged()
}
val maxBehaviorHeightPercentage = if (isLandscape) 1f else 0.66f
val minFrameHeightDenominator = if (isLandscape) 1 else 2
callControls.viewTreeObserver.addOnGlobalLayoutListener {
if (callControls.height > 0 && previousCallControlHeightData.hasChanged(callControls.height, coordinator.height)) {
previousCallControlHeightData = HeightData(callControls.height, coordinator.height)
controlPeakHeight = callControls.height + callControls.y.toInt()
val controlPeakHeight = callControls.height + callControls.y.toInt() + 16.dp
behavior.peekHeight = controlPeakHeight
frame.minimumHeight = coordinator.height / 2
behavior.maxHeight = (coordinator.height.toFloat() * 0.66f).toInt()
frame.minimumHeight = coordinator.height / minFrameHeightDenominator
behavior.maxHeight = (coordinator.height.toFloat() * maxBehaviorHeightPercentage).toInt()
webRtcCallView.post { onControlTopChanged() }
}
}
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
overflowPopupWindow.dismiss()
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
controlsAndInfoViewModel.resetScrollState()
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
cancelScheduledHide()
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
controlsAndInfoViewModel.resetScrollState()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = infoTranslationDistance - (infoTranslationDistance * callInfoComposeView.alpha)
}
})
webRtcCallView.addWindowInsetsListener(object : InsetAwareConstraintLayout.WindowInsetsListener {
override fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int) {
if (navigationBar > 0) {
@@ -210,20 +190,77 @@ class ControlsAndInfoController(
setName(bundle.getString(resultKey)!!)
}
}
}
fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_HIDDEN
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
overflowPopupWindow.dismiss()
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
controlsAndInfoViewModel.resetScrollState()
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
cancelScheduledHide()
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
controlsAndInfoViewModel.resetScrollState()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
updateCallSheetVisibilities(slideOffset)
}
})
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop))
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE
}
fun addVisibilityListener(listener: BottomSheetVisibilityListener): Boolean {
return bottomSheetVisibilityListeners.add(listener)
}
fun onStateRestored() {
when (behavior.state) {
BottomSheetBehavior.STATE_EXPANDED -> {
showCallInfo()
updateCallSheetVisibilities(1f)
}
BottomSheetBehavior.STATE_HIDDEN -> {
hide()
updateCallSheetVisibilities(-1f)
}
else -> {
showControls()
updateCallSheetVisibilities(0f)
}
}
}
fun showCallInfo() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
@@ -282,6 +319,20 @@ class ControlsAndInfoController(
hide(delay = HIDE_CONTROL_DELAY)
}
private fun updateCallSheetVisibilities(slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE - (INFO_TRANSLATION_DISTANCE * callInfoComposeView.alpha)
}
private fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
}
private fun showOrHideControlsOnUpdate(previousState: WebRtcControls) {
if (controlState == WebRtcControls.PIP || controlState.displayErrorControls()) {
hide()
@@ -334,6 +385,13 @@ class ControlsAndInfoController(
constraints.applyTo(callControls)
toggleCameraDirectionView.visible = controlState.displayCameraToggle()
waitingToBeLetIn.visible = controlState.displayWaitingToBeLetIn()
if (controlState.displayWaitingToBeLetIn()) {
waitingToBeLetInProgressDrawable.setVisible(true, false)
} else {
waitingToBeLetInProgressDrawable.stop()
}
}
private fun onScheduledHide() {
@@ -346,34 +404,6 @@ class ControlsAndInfoController(
handler?.removeCallbacks(scheduleHideControlsRunnable)
}
private fun alphaControls(slideOffset: Float): Float {
return if (slideOffset <= CONTROL_FADE_OUT_START) {
1f
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
0f
} else {
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
}
}
private fun alphaCallInfo(slideOffset: Float): Float {
return if (slideOffset >= INFO_FADE_IN_DONE) {
1f
} else if (slideOffset <= INFO_FADE_IN_START) {
0f
} else {
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
}
}
override fun dispose() {
disposables.dispose()
}
override fun isDisposed(): Boolean {
return disposables.isDisposed
}
override fun onShareLinkClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(webRtcCallActivity)
@@ -458,13 +488,36 @@ class ControlsAndInfoController(
displayMuteAudio() != previousState.displayMuteAudio() ||
displayRingToggle() != previousState.displayRingToggle() ||
displayOverflow() != previousState.displayOverflow() ||
displayEndCall() != previousState.displayEndCall()
displayEndCall() != previousState.displayEndCall() ||
displayWaitingToBeLetIn() != previousState.displayWaitingToBeLetIn() ||
(previousState == WebRtcControls.PIP && this != WebRtcControls.PIP)
}
private fun alphaControls(slideOffset: Float): Float {
return if (slideOffset <= CONTROL_FADE_OUT_START) {
1f
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
0f
} else {
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
}
}
private fun alphaCallInfo(slideOffset: Float): Float {
return if (slideOffset >= INFO_FADE_IN_DONE) {
1f
} else if (slideOffset <= INFO_FADE_IN_START) {
0f
} else {
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
}
}
@Parcelize
private data class HeightData(
val controlHeight: Int = 0,
val coordinatorHeight: Int = 0
) {
) : Parcelable {
fun hasChanged(controlHeight: Int, coordinatorHeight: Int): Boolean {
return controlHeight != this.controlHeight || coordinatorHeight != this.coordinatorHeight
}

View File

@@ -186,7 +186,7 @@ public final class ConversationUpdateItem extends FrameLayout
shouldCollapse(messageRecord, nextMessageRecord),
hasWallpaper);
presentActionButton(hasWallpaper, conversationMessage.getMessageRecord().isBoostRequest());
presentActionButton(hasWallpaper, conversationMessage.getMessageRecord().isReleaseChannelDonationRequest());
updateSelectedState();
}
@@ -538,7 +538,7 @@ public final class ConversationUpdateItem extends FrameLayout
eventListener.onBlockJoinRequest(conversationMessage.getMessageRecord().getFromRecipient());
}
});
} else if (conversationMessage.getMessageRecord().isBoostRequest()) {
} else if (conversationMessage.getMessageRecord().isReleaseChannelDonationRequest()) {
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {

View File

@@ -1698,14 +1698,13 @@ class ConversationFragment :
}
private fun initializeMediaKeyboard() {
val isSystemEmojiPreferred = SignalStore.settings.isPreferSystemEmoji
val keyboardMode: TextSecurePreferences.MediaKeyboardMode = TextSecurePreferences.getMediaKeyboardMode(requireContext())
val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())
inputPanel.showMediaKeyboardToggle(true)
val keyboardPage = when (keyboardMode) {
TextSecurePreferences.MediaKeyboardMode.EMOJI -> if (isSystemEmojiPreferred) KeyboardPage.STICKER else KeyboardPage.EMOJI
TextSecurePreferences.MediaKeyboardMode.EMOJI -> KeyboardPage.EMOJI
TextSecurePreferences.MediaKeyboardMode.STICKER -> KeyboardPage.STICKER
TextSecurePreferences.MediaKeyboardMode.GIF -> if (RemoteConfig.gifSearchAvailable) KeyboardPage.GIF else KeyboardPage.STICKER
}
@@ -1746,35 +1745,35 @@ class ConversationFragment :
inputPanel.isRecordingInLockedMode -> {
buttonToggle.display(sendButton)
quickAttachment.show()
inlineAttachment.hide()
inlineAttachment.hide(true)
}
inputPanel.inEditMessageMode() -> {
buttonToggle.display(sendEditButton)
quickAttachment.hide()
inlineAttachment.hide()
quickAttachment.hide(false)
inlineAttachment.hide(false)
}
draftViewModel.voiceNoteDraft != null -> {
buttonToggle.display(sendButton)
quickAttachment.hide()
inlineAttachment.hide()
quickAttachment.hide(true)
inlineAttachment.hide(true)
}
composeText.text.isNullOrBlank() && !attachmentManager.isAttachmentPresent -> {
buttonToggle.display(binding.conversationInputPanel.attachButton)
quickAttachment.show()
inlineAttachment.hide()
inlineAttachment.hide(true)
}
else -> {
buttonToggle.display(sendButton)
quickAttachment.hide()
quickAttachment.hide(true)
if (!attachmentManager.isAttachmentPresent && !linkPreviewViewModel.hasLinkPreviewUi) {
inlineAttachment.show()
} else {
inlineAttachment.hide()
inlineAttachment.hide(true)
}
}
}

View File

@@ -650,7 +650,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
return emphasisAdded(context, context.getString(R.string.ThreadRecord_message_could_not_be_processed), defaultTint);
} else if (MessageTypes.isProfileChange(thread.getType())) {
return emphasisAdded(context, "", defaultTint);
} else if (MessageTypes.isChangeNumber(thread.getType()) || MessageTypes.isBoostRequest(thread.getType())) {
} else if (MessageTypes.isChangeNumber(thread.getType()) || MessageTypes.isReleaseChannelDonationRequest(thread.getType())) {
return emphasisAdded(context, "", defaultTint);
} else if (MessageTypes.isBadDecryptType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_delivery_issue), defaultTint);

View File

@@ -112,7 +112,12 @@ object CrashConfig {
return false
}
val partsPerMillion = (1_000_000 * percent).toInt()
if (percent <= 0f || percent > 100f) {
return false
}
val fraction = percent / 100
val partsPerMillion = (1_000_000 * fraction).toInt()
val bucket = BucketingUtil.bucket(RemoteConfig.CRASH_PROMPT_CONFIG, aci.rawUuid, 1_000_000)
return partsPerMillion > bucket
}

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