mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-05 01:38:54 +01:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a7d3dc08 | ||
|
|
3380fa722d | ||
|
|
216f57f3ea | ||
|
|
bf4aa85ac3 | ||
|
|
e4966da3ef | ||
|
|
79c7c2345f | ||
|
|
d516037be9 | ||
|
|
008b38594d | ||
|
|
15b59457f7 | ||
|
|
5ddd1651ee | ||
|
|
8ca89d2024 | ||
|
|
585c8cd863 | ||
|
|
faf6ab233f | ||
|
|
95cbc91bf0 | ||
|
|
9480e23455 | ||
|
|
727a0f8273 | ||
|
|
279e55d65f | ||
|
|
c36fba2ab7 | ||
|
|
1d0997379f | ||
|
|
1a7611d505 | ||
|
|
36846301de | ||
|
|
b8e81e6677 | ||
|
|
3d169bffd0 | ||
|
|
556a25447e | ||
|
|
b42e48a08a | ||
|
|
b1a4e889bc | ||
|
|
e6fb01a67b | ||
|
|
6c042f6e47 | ||
|
|
f87ff58701 | ||
|
|
4fb335de28 | ||
|
|
725d8dc85d | ||
|
|
e76153b2fd | ||
|
|
0b98901634 | ||
|
|
57feb272d2 | ||
|
|
7b0badef19 | ||
|
|
36640edfee | ||
|
|
4e07c07ca9 | ||
|
|
31ddc5bcc0 | ||
|
|
c80f459c37 | ||
|
|
2a6dab41f5 | ||
|
|
e6d8e36141 | ||
|
|
816c9360cd | ||
|
|
82c3265be5 | ||
|
|
ab03a627da | ||
|
|
81a45ddc09 | ||
|
|
f1115130b2 | ||
|
|
2d557215a0 | ||
|
|
cc806a2f79 | ||
|
|
853c934a5a | ||
|
|
f1ba947a59 | ||
|
|
44b2c62a0e | ||
|
|
06d475fb6e | ||
|
|
36dface175 | ||
|
|
86cf8200b5 | ||
|
|
973dc72cfa | ||
|
|
eb59afc33c | ||
|
|
625ca832b0 | ||
|
|
bc6face873 | ||
|
|
0aeaec8b67 | ||
|
|
b70b058925 | ||
|
|
e17cf37799 | ||
|
|
330debcf37 | ||
|
|
6641cc4806 | ||
|
|
bd3ab2cc38 | ||
|
|
fa487e1885 | ||
|
|
eb2fc33940 | ||
|
|
c39739bcb4 | ||
|
|
e7720640d1 |
@@ -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)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 Signal’s 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -430,6 +430,7 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
is VerificationCodeRequestResult.RegistrationLocked ->
|
||||
store.update {
|
||||
it.copy(
|
||||
lockedTimeRemaining = result.timeRemaining,
|
||||
svr2Credentials = result.svr2Credentials,
|
||||
svr3Credentials = result.svr3Credentials
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -7,5 +7,5 @@ data class ChatsSettingsState(
|
||||
val useSystemEmoji: Boolean,
|
||||
val enterKeySends: Boolean,
|
||||
val localBackupsEnabled: Boolean,
|
||||
val remoteBackupsEnabled: Boolean
|
||||
val canAccessRemoteBackupsSettings: Boolean
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,7 +40,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteChatHistory() {
|
||||
viewModelScope.launch {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
SignalDatabase.threads.deleteAllConversations()
|
||||
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -78,7 +78,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
|
||||
}
|
||||
|
||||
private fun show() {
|
||||
if (!enabled) {
|
||||
if (!enabled || parent.windowToken == null) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user