Compare commits

...

47 Commits

Author SHA1 Message Date
Alex Hart
245f7d3e03 Bump version to 6.3.2 2022-11-18 17:02:44 -04:00
Alex Hart
972ce41689 Updated language translations. 2022-11-18 16:54:16 -04:00
Alex Hart
be12a17ff7 Add handling for payment_intent with missing status. 2022-11-18 13:22:30 -04:00
Alex Hart
0c615e2fc2 Bump version to 6.3.1 2022-11-17 16:43:49 -04:00
Alex Hart
6829257a83 Updated language translations. 2022-11-17 16:39:38 -04:00
Nicholas
b7b7a04fad Improve animations for video seekbar. 2022-11-17 15:33:15 -05:00
Cody Henthorne
50084f8f73 Fix debuglog system info formatting bug. 2022-11-17 11:54:51 -05:00
Alex Hart
04e8235cfc Add group stories education sheet. 2022-11-17 12:35:17 -04:00
Alex Hart
0df3096241 Fix issue where gallery image was overlapped by count. 2022-11-17 12:18:32 -04:00
Alex Hart
29f22d515a Set story image post minimum duration to 5s. 2022-11-17 12:13:07 -04:00
Alex Hart
9931496b0f Fix crash when toggling pills. 2022-11-17 12:06:36 -04:00
Alex Hart
950363a4e9 Don't wrap donation errors. 2022-11-17 11:07:20 -04:00
Alex Hart
3469e8d0e0 Set brightness to 66% when taking a selfie. 2022-11-17 10:02:02 -04:00
Alex Hart
586339575f Fix menu visibility for chat filters. 2022-11-16 16:53:27 -04:00
Varsha
807a0e02a2 Fix memory leak in payment transfer fragment. 2022-11-16 15:11:09 -05:00
Cody Henthorne
afb2b1a1a2 Do not include self in exported SMS threads. 2022-11-16 14:18:57 -05:00
Alex Hart
a8946961d5 Bump version to 6.3.0 2022-11-16 15:14:49 -04:00
Alex Hart
026aaac451 Updated language translations. 2022-11-16 15:10:26 -04:00
Alex Hart
159f319d77 Update caption bar readability in stories. 2022-11-16 15:05:47 -04:00
Greyson Parrelli
cf00995b6f Guarantee table export order is valid. 2022-11-16 15:05:47 -04:00
Cody Henthorne
7c60c32918 Add re-export SMS support and hard code Phase 0. 2022-11-16 15:05:47 -04:00
Cody Henthorne
fd1d2ec8fc Ignore group ring requests if we are already in the call. 2022-11-16 15:05:47 -04:00
Alex Hart
a11c40e4fe Add credit card support to badge gifting. 2022-11-16 15:05:47 -04:00
Greyson Parrelli
1eb2f51398 Convert AVIF files to jpegs. 2022-11-16 15:05:47 -04:00
Nicholas
13ed122c3e Null check RecyclerView references in search bar callbacks. 2022-11-16 15:05:47 -04:00
Alex Hart
fa02ee1d3d Skip re-emission of duplicate StoryPosts. 2022-11-16 15:05:47 -04:00
Alex Hart
4908e39308 Skip prefetch call if no stories need to be cached. 2022-11-16 15:05:47 -04:00
Alex Hart
ad001d585e Utilize center-inside transform to ensure proper downsampling of cached images. 2022-11-16 15:05:47 -04:00
Greyson Parrelli
3fd5e55363 Improve RecipientDatabase tests. 2022-11-16 15:05:47 -04:00
Greyson Parrelli
ebc1bc3f7f Fix issue where non-ascii characters didn't show inline emoji suggestions.
Fixes #12579
2022-11-16 15:05:47 -04:00
Cody Henthorne
c51e13fd30 Ignore rings from non-admins in announcement only groups and rev feature flag. 2022-11-16 15:05:47 -04:00
Nicholas
fd37613f2f Don't fade in media preview controls if hidden. 2022-11-16 15:05:47 -04:00
Greyson Parrelli
eb921f3103 Don't show megaphones in landscape. 2022-11-16 15:05:47 -04:00
Varsha
d5b6c47670 Fix memory leak in payments home. 2022-11-16 15:05:47 -04:00
Varsha
a4494b58f0 Fix memory leaks in payments home and confirm payment view models. 2022-11-16 15:05:47 -04:00
Varsha
b0c68b12ed Fix memory leak in create payment fragment. 2022-11-16 15:05:47 -04:00
Varsha
b47e5f2fa9 Fix memory leak in contact selection list. 2022-11-16 15:05:47 -04:00
Alex Hart
bba1315906 Add chat filter support behind a flag. 2022-11-16 15:05:47 -04:00
Alex Hart
3e2ecdaaa9 Add blur hashes behind videos. 2022-11-15 16:26:19 -04:00
Nicholas
fb8e81cf50 Center selected item in media rail.
Fixes #12582
2022-11-15 16:26:19 -04:00
Cody Henthorne
52a5fb8ea2 Fix crash when showing a message with a button without media. 2022-11-15 16:26:19 -04:00
Alex Hart
b2f3867b0b Add dynamic duration to stories with captions. 2022-11-15 16:26:19 -04:00
Alex Hart
45ca3bd7cf Show default gallery icon if permissions is disabled or media is not available. 2022-11-15 16:26:19 -04:00
Alex Hart
74b7057608 Brighten camera screen if under 66%. 2022-11-15 16:26:19 -04:00
Robotwombat
3a060c7a79 Update some info on the README.
* Removed the mention of SMS/MMS support.
* Replaced the Signal description with some direct text from either Signal's Play Store listing or from signal.org
* Fixed some capitalization errors
* Replaced "Open Whisper Systems" with "Signal" in the 'Contributing Ideas' section

Closes #12597
2022-11-15 16:26:19 -04:00
Jim Gustafson
de426d22bf Update to RingRTC v2.21.5 2022-11-15 16:26:19 -04:00
Alex Hart
14549fd401 Fix issue where SystemWindwInsetsSetter didn't respect type on older API levels. 2022-11-15 16:26:19 -04:00
193 changed files with 5888 additions and 2277 deletions

View File

@@ -1,10 +1,10 @@
# Signal Android
Signal is a messaging app for simple private communication with friends.
Signal is a simple, powerful, and secure messenger.
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
Signal uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely. Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected. Signals advanced privacy-preserving technology is always enabled, so you can focus on sharing the moments that matter with the people who matter to you.
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
Currently available on the Play Store and [signal.org](https://signal.org/android/apk/).
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
@@ -18,7 +18,7 @@ Want to live life on the bleeding edge and help out with testing?
You can subscribe to Signal Android Beta releases here:
https://play.google.com/apps/testing/org.thoughtcrime.securesms
If you're interested in a life of peace and tranquility, stick with the standard releases.
## Contributing Code
@@ -28,7 +28,7 @@ If you're new to the Signal codebase, we recommend going through our issues and
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.
## Contributing Ideas
Have something you want to say about Open Whisper Systems projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org).
Have something you want to say about Signal projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org).
Help
====

View File

@@ -50,8 +50,8 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 1164
def canonicalVersionName = "6.2.3"
def canonicalVersionCode = 1167
def canonicalVersionName = "6.3.2"
def postFixSize = 100
def abiPostFix = ['universal' : 0,

View File

@@ -1,482 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_processPnpTuple {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var smsDatabase: SmsDatabase
private lateinit var threadDatabase: ThreadDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
smsDatabase = SignalDatabase.sms
threadDatabase = SignalDatabase.threads
ensureDbEmpty()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
@Test
fun noMatch_e164Only() {
test {
process(E164_A, null, null)
expect(E164_A, null, null)
}
}
@Test
fun noMatch_e164AndPni() {
test {
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
}
}
@Test
fun noMatch_aciOnly() {
test {
process(null, null, ACI_A)
expect(null, null, ACI_A)
}
}
@Test(expected = IllegalStateException::class)
fun noMatch_noData() {
test {
process(null, null, null)
}
}
@Test
fun noMatch_allFields() {
test {
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun fullMatch() {
test {
given(E164_A, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyE164Matches() {
test {
given(E164_A, null, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyE164Matches_differentAci() {
test {
given(E164_A, null, ACI_B)
process(E164_A, PNI_A, ACI_A)
expect(null, null, ACI_B)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun e164AndPniMatches() {
test {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun e164AndAciMatches() {
test {
given(E164_A, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyPniMatches() {
test {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun pniAndAciMatches() {
test {
given(null, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyAciMatches() {
test {
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
test {
given(E164_A, PNI_B, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
}
}
@Test
fun e164AndPniMatches_noExistingSession() {
test {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyPniMatches_noExistingSession() {
test {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun onlyPniMatches_noExistingPniSession_changeNumber() {
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test {
given(E164_B, PNI_A, null, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun pniAndAciMatches_changeNumber() {
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test {
given(E164_B, PNI_A, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun onlyAciMatches_changeNumber() {
test {
given(E164_B, null, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun merge_e164Only_pniOnly_aciOnly() {
test {
given(E164_A, null, null)
given(null, PNI_A, null)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun merge_e164Only_pniOnly_noAciProvided() {
test {
given(E164_A, null, null)
given(null, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expectDeleted()
}
}
@Test
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
test {
given(E164_A, null, null)
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
}
}
@Test
fun merge_e164Only_pniAndE164_noAciProvided() {
test {
given(E164_A, null, null)
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
}
}
@Test
fun merge_e164AndPni_pniOnly_noAciProvided() {
test {
given(E164_A, PNI_B, null)
given(null, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expectDeleted()
}
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
test {
given(E164_A, PNI_B, null)
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
}
}
@Test
fun merge_e164AndPni_aciOnly() {
test {
given(E164_A, PNI_A, null)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
test {
given(E164_B, PNI_A, null)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_B, null, null)
expect(E164_A, PNI_A, ACI_A)
}
}
@Test
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
test {
given(E164_A, PNI_A, null)
given(E164_B, PNI_B, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
@Test
fun merge_e164AndPni_e164Aci_changeNumber() {
test {
given(E164_A, PNI_A, null)
given(E164_B, null, ACI_A, createThread = true)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
}
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientDatabase.TABLE_NAME,
null,
contentValuesOf(
RecipientDatabase.PHONE to e164,
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientDatabase.PNI_COLUMN to pni?.toString(),
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun require(id: RecipientId): IdRecord {
return get(id)!!
}
private fun get(id: RecipientId): IdRecord? {
SignalDatabase.rawDatabase
.select(RecipientDatabase.ID, RecipientDatabase.PHONE, RecipientDatabase.SERVICE_ID, RecipientDatabase.PNI_COLUMN)
.from(RecipientDatabase.TABLE_NAME)
.where("${RecipientDatabase.ID} = ?", id)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
IdRecord(
id = RecipientId.from(cursor.requireLong(RecipientDatabase.ID)),
e164 = cursor.requireString(RecipientDatabase.PHONE),
sid = ServiceId.parseOrNull(cursor.requireString(RecipientDatabase.SERVICE_ID)),
pni = PNI.parseOrNull(cursor.requireString(RecipientDatabase.PNI_COLUMN))
)
} else {
null
}
}
}
private fun ensureDbEmpty() {
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME} WHERE ${RecipientDatabase.DISTRIBUTION_LIST_ID} IS NULL ", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
/**
* Baby DSL for making tests readable.
*/
private fun test(init: TestCase.() -> Unit): TestCase {
val test = TestCase()
test.init()
return test
}
private inner class TestCase {
private val generatedIds: LinkedHashSet<RecipientId> = LinkedHashSet()
private var expectCount = 0
private lateinit var outputRecipientId: RecipientId
fun given(e164: String?, pni: PNI?, aci: ACI?, createThread: Boolean = false) {
val id = insert(e164, pni, aci)
generatedIds += id
if (createThread) {
// Create a thread and throw a dummy message in it so it doesn't get automatically deleted
threadDatabase.getOrCreateThreadIdFor(Recipient.resolved(id))
smsDatabase.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
}
}
fun process(e164: String?, pni: PNI?, aci: ACI?) {
SignalDatabase.rawDatabase.beginTransaction()
try {
outputRecipientId = recipientDatabase.processPnpTuple(e164, pni, aci, pniVerified = false).finalId
generatedIds += outputRecipientId
SignalDatabase.rawDatabase.setTransactionSuccessful()
} finally {
SignalDatabase.rawDatabase.endTransaction()
}
}
fun expect(e164: String?, pni: PNI?, aci: ACI?) {
expect(generatedIds.elementAt(expectCount++), e164, pni, aci)
}
fun expect(id: RecipientId, e164: String?, pni: PNI?, aci: ACI?) {
val record: IdRecord = require(id)
assertEquals(e164, record.e164)
assertEquals(pni, record.pni)
assertEquals(aci ?: pni, record.sid)
}
fun expectDeleted() {
expectDeleted(generatedIds.elementAt(expectCount++))
}
fun expectDeleted(id: RecipientId) {
assertNull(get(id))
}
fun expectChangeNumberEvent() {
assertEquals(1, smsDatabase.getChangeNumberMessageCount(outputRecipientId))
}
}
private data class IdRecord(
val id: RecipientId,
val e164: String?,
val sid: ServiceId?,
val pni: PNI?,
)
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}
}

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.KbsEnclave
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.Verb
import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.util.Base64
@@ -41,6 +42,7 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
private val uncensoredConfiguration: SignalServiceConfiguration
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
private val keyBackupService: KeyBackupService
private val recipientCache: LiveRecipientCache
init {
runSync {
@@ -81,6 +83,8 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
}
keyBackupService = mock()
recipientCache = LiveRecipientCache(application) { r -> r.run() }
}
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
@@ -91,6 +95,10 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
return keyBackupService
}
override fun provideRecipientCache(): LiveRecipientCache {
return recipientCache
}
companion object {
lateinit var webServer: MockWebServer
private set

View File

@@ -356,6 +356,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
return view;
}
@Override
public void onDestroyView() {
super.onDestroyView();
constraintLayout = null;
}
private @NonNull Bundle safeArguments() {
return getArguments() != null ? getArguments() : new Bundle();
}

View File

@@ -8,6 +8,7 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
@@ -19,6 +20,7 @@ import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.SetUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
@@ -62,10 +64,15 @@ import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedList;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
@@ -84,7 +91,11 @@ public class FullBackupExporter extends FullBackupBase {
private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
private static final long FINAL_MESSAGE_COUNT = 1L;
private static final Set<String> BLACKLISTED_TABLES = SetUtil.newHashSet(
/**
* Tables in list will still have their *schema* exported (so the tables will be created),
* but we will not export the actual contents.
*/
private static final Set<String> TABLE_CONTENT_BLOCKLIST = SetUtil.newHashSet(
SignedPreKeyDatabase.TABLE_NAME,
OneTimePreKeyDatabase.TABLE_NAME,
SessionDatabase.TABLE_NAME,
@@ -175,7 +186,7 @@ public class FullBackupExporter extends FullBackupBase {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
} else if (!TABLE_CONTENT_BLOCKLIST.contains(table)) {
count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal);
}
stopwatch.split("table::" + table);
@@ -229,7 +240,7 @@ public class FullBackupExporter extends FullBackupBase {
count += getCount(input, BackupCountQueries.getAttachmentCount());
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
count += getCount(input, "SELECT COUNT(*) FROM " + table);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
} else if (!TABLE_CONTENT_BLOCKLIST.contains(table)) {
count += getCount(input, "SELECT COUNT(*) FROM " + table);
}
}
@@ -266,31 +277,112 @@ public class FullBackupExporter extends FullBackupBase {
private static List<String> exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream)
throws IOException
{
List<String> tables = new LinkedList<>();
List<String> tablesInOrder = getTablesToExportInOrder(input);
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master", null)) {
Map<String, String> createStatementsByTable = new HashMap<>();
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master WHERE type = 'table' AND sql NOT NULL", null)) {
while (cursor != null && cursor.moveToNext()) {
String sql = cursor.getString(0);
String name = cursor.getString(1);
String type = cursor.getString(2);
if (sql != null) {
boolean isSmsFtsSecretTable = name != null && !name.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME);
boolean isMmsFtsSecretTable = name != null && !name.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME);
boolean isEmojiFtsSecretTable = name != null && !name.equals(EmojiSearchDatabase.TABLE_NAME) && name.startsWith(EmojiSearchDatabase.TABLE_NAME);
createStatementsByTable.put(name, sql);
}
}
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable && !isEmojiFtsSecretTable) {
if ("table".equals(type)) {
tables.add(name);
}
for (String table : tablesInOrder) {
String statement = createStatementsByTable.get(table);
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(cursor.getString(0)).build());
}
if (statement != null) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(statement).build());
} else {
throw new IOException("Failed to find a create statement for table: " + table);
}
}
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master where type != 'table' AND sql NOT NULL", null)) {
while (cursor != null && cursor.moveToNext()) {
String sql = cursor.getString(0);
String name = cursor.getString(1);
if (isTableAllowed(name)) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(sql).build());
}
}
}
return tables;
return tablesInOrder;
}
/**
* Returns the list of tables we should export, in the order they should be exported in.
* The order is chosen to ensure we won't violate any foreign key constraints when we import them.
*/
private static List<String> getTablesToExportInOrder(@NonNull SQLiteDatabase input) {
List<String> tables = SqlUtil.getAllTables(input)
.stream()
.filter(FullBackupExporter::isTableAllowed)
.sorted()
.collect(Collectors.toList());
Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
for (String table : tables) {
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
}
return computeTableOrder(dependsOn);
}
@VisibleForTesting
static List<String> computeTableOrder(@NonNull Map<String, Set<String>> dependsOn) {
List<String> rootNodes = dependsOn.keySet()
.stream()
.filter(table -> {
boolean nothingDependsOnIt = dependsOn.values().stream().noneMatch(it -> it.contains(table));
return nothingDependsOnIt;
})
.sorted()
.collect(Collectors.toList());
LinkedHashSet<String> outputOrder = new LinkedHashSet<>();
for (String root : rootNodes) {
postOrderTraversal(root, dependsOn, outputOrder);
}
return new ArrayList<>(outputOrder);
}
private static void postOrderTraversal(String current, Map<String, Set<String>> dependsOn, LinkedHashSet<String> outputOrder) {
Set<String> dependencies = dependsOn.get(current);
if (dependencies == null || dependencies.isEmpty()) {
outputOrder.add(current);
return;
}
for (String dependency : dependencies) {
postOrderTraversal(dependency, dependsOn, outputOrder);
}
outputOrder.add(current);
}
private static boolean isTableAllowed(@Nullable String table) {
if (table == null) {
return true;
}
boolean isReservedTable = table.startsWith("sqlite_");
boolean isSmsFtsSecretTable = !table.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME);
boolean isMmsFtsSecretTable = !table.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME);
boolean isEmojiFtsSecretTable = !table.equals(EmojiSearchDatabase.TABLE_NAME) && table.startsWith(EmojiSearchDatabase.TABLE_NAME);
return !isReservedTable &&
!isSmsFtsSecretTable &&
!isMmsFtsSecretTable &&
!isEmojiFtsSecretTable;
}
private static int exportTable(@NonNull String table,

View File

@@ -4,10 +4,12 @@ import android.content.DialogInterface
import android.view.KeyEvent
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
@@ -20,8 +22,10 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
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.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
@@ -33,10 +37,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
@@ -48,7 +53,8 @@ class GiftFlowConfirmationFragment :
),
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
EmojiSearchFragment.Callback,
DonationCheckoutDelegate.Callback {
companion object {
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
@@ -67,9 +73,9 @@ class GiftFlowConfirmationFragment :
private val lifecycleDisposable = LifecycleDisposable()
private var errorDialog: DialogInterface? = null
private var donationCheckoutDelegate: DonationCheckoutDelegate? = null
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
@@ -81,7 +87,7 @@ class GiftFlowConfirmationFragment :
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationPaymentComponent = requireListener()
donationCheckoutDelegate = DonationCheckoutDelegate(this, this)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
@@ -98,13 +104,29 @@ class GiftFlowConfirmationFragment :
emojiKeyboard.setFragmentManager(childFragmentManager)
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button)
googlePayButton.setOnGooglePayClickListener {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
val continueButton = requireView().findViewById<MaterialButton>(R.id.continue_button)
continueButton.setOnClickListener {
findNavController().safeNavigate(
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
with(viewModel.snapshot) {
GatewayRequest(
donateToSignalType = DonateToSignalType.GIFT,
badge = giftBadge!!,
label = getString(R.string.preferences__one_time),
price = giftPrices[currency]!!.amount,
currencyCode = currency.currencyCode,
level = giftLevel!!,
recipientId = recipient!!.id,
additionalMessage = additionalMessage?.toString()
)
}
)
)
}
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
val amountView = requireView().findViewById<TextView>(R.id.amount)
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
textInputViewHolder.onAttachedToWindow()
@@ -165,29 +187,17 @@ class GiftFlowConfirmationFragment :
} else {
processingDonationPaymentDialog.dismiss()
}
amountView.text = FiatMoneyUtil.format(resources, state.giftPrices[state.currency]!!, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onPaymentError(donationError)
}
lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
when (donationEvent) {
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
}
}
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
}
}
override fun onDestroyView() {
@@ -196,6 +206,7 @@ class GiftFlowConfirmationFragment :
processingDonationPaymentDialog.dismiss()
debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss()
donationCheckoutDelegate = null
}
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
@@ -225,16 +236,6 @@ class GiftFlowConfirmationFragment :
}
}
private fun onPaymentConfirmed() {
val mainActivityIntent = MainActivity.clearTop(requireContext())
val conversationIntent = ConversationIntents
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
.withGiftBadge(viewModel.snapshot.giftBadge!!)
.build()
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
}
private fun onPaymentError(throwable: Throwable?) {
Log.w(TAG, "onPaymentError", throwable, true)
@@ -276,4 +277,24 @@ class GiftFlowConfirmationFragment :
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
}
}
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
}
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
val mainActivityIntent = MainActivity.clearTop(requireContext())
val conversationIntent = ConversationIntents
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
.withGiftBadge(viewModel.snapshot.giftBadge!!)
.build()
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
}
override fun onProcessorActionProcessed() = Unit
}

View File

@@ -9,18 +9,14 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -33,11 +29,7 @@ class GiftFlowStartFragment : DSLSettingsFragment(
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() },
factoryProducer = {
GiftFlowViewModel.Factory(
GiftFlowRepository(),
requireListener<DonationPaymentComponent>().stripeRepository,
OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
)
GiftFlowViewModel.Factory(GiftFlowRepository())
}
)

View File

@@ -1,14 +1,9 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -16,19 +11,9 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.badges.gifts.Gifts
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
@@ -39,13 +24,9 @@ import java.util.Currency
* Maintains state as a user works their way through the gift flow.
*/
class GiftFlowViewModel(
private val giftFlowRepository: GiftFlowRepository,
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
private val giftFlowRepository: GiftFlowRepository
) : ViewModel() {
private var giftToPurchase: Gift? = null
private val store = RxStore(
GiftFlowState(
currency = SignalStore.donationsValues().getOneTimeCurrency()
@@ -133,86 +114,6 @@ class GiftFlowViewModel(
return store.state.giftPrices.keys.map { it.currencyCode }
}
fun requestTokenFromGooglePay(label: String) {
val giftLevel = store.state.giftLevel ?: return
val giftPrice = store.state.giftPrices[store.state.currency] ?: return
val giftRecipient = store.state.recipient?.id ?: return
this.giftToPurchase = Gift(giftLevel, giftPrice)
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
disposables += giftFlowRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) }
stripeRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
},
onError = this::onPaymentFlowError
)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
val gift = giftToPurchase
giftToPurchase = null
val recipient = store.state.recipient?.id
stripeRepository.onActivityResult(
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
if (gift != null && recipient != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(gift.price, recipient, gift.level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
.flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts.
.andThen(oneTimeDonationRepository.waitForOneTimeRedemption(gift.price, paymentIntent.intentId, recipient, store.state.additionalMessage?.toString(), gift.level))
}.subscribeBy(
onError = this@GiftFlowViewModel::onPaymentFlowError,
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!))
}
)
} else {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.GIFT, googlePayException))
}
override fun onCancelled() {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
)
}
private fun onPaymentFlowError(throwable: Throwable) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
}
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
@@ -250,16 +151,12 @@ class GiftFlowViewModel(
}
class Factory(
private val repository: GiftFlowRepository,
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
private val repository: GiftFlowRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
GiftFlowViewModel(
repository,
stripeRepository,
oneTimeDonationRepository
repository
)
) as T
}

View File

@@ -62,7 +62,10 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
)
noPadTextPref(
title = DSLSettingsText.from(getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())))
title = DSLSettingsText.from(
getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(37f).toInt())

View File

@@ -70,6 +70,16 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
}
)
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages_again),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__exporting_again_can_result_in_duplicate_messages),
onClick = {
SmsExportDialogs.showSmsReExportDialog(requireContext()) {
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext(), isReExport = true))
}
}
)
dividerPref()
}
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit

View File

@@ -98,6 +98,16 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
}
)
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages_again),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__exporting_again_can_result_in_duplicate_messages),
onClick = {
SmsExportDialogs.showSmsReExportDialog(requireContext()) {
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext(), isReExport = true))
}
}
)
dividerPref()
}
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit

View File

@@ -174,14 +174,20 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
}
// We need to get the status and payment id from the intent.
/**
* Note: There seem to be times when PaymentIntent does not return a status. In these cases, we assume
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
* expect an error later in the chain to inform us of this.
*/
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
if (it.status == null) {
Log.d(TAG, "Returned payment intent had a null status.", true)
}
StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)

View File

@@ -8,23 +8,17 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.LottieAnimationView
import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
@@ -33,16 +27,8 @@ import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
@@ -57,16 +43,17 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Currency
/**
* Unified donation fragment which allows users to choose between monthly or one-time donations.
*/
class DonateToSignalFragment : DSLSettingsFragment(
layoutId = R.layout.donate_to_signal_fragment
) {
class DonateToSignalFragment :
DSLSettingsFragment(
layoutId = R.layout.donate_to_signal_fragment
),
DonationCheckoutDelegate.Callback {
companion object {
private val TAG = Log.tag(DonateToSignalFragment::class.java)
@@ -98,18 +85,10 @@ class DonateToSignalFragment : DSLSettingsFragment(
DonateToSignalViewModel.Factory(args.startType)
})
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
donationPaymentComponent = requireListener()
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository)
}
)
private val disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind)
private lateinit var donationPaymentComponent: DonationPaymentComponent
private var donationCheckoutDelegate: DonationCheckoutDelegate? = null
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
@@ -133,23 +112,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: MappingAdapter) {
donationPaymentComponent = requireListener()
registerGooglePayCallback()
setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle ->
val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!!
handleGatewaySelectionResponse(response)
}
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
handleDonationProcessorActionResult(result)
}
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
handleCreditCardResult(result)
}
donationCheckoutDelegate = DonationCheckoutDelegate(this, this)
val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -242,6 +205,11 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
override fun onDestroyView() {
super.onDestroyView()
donationCheckoutDelegate = null
}
private fun getConfiguration(state: DonateToSignalState): DSLConfiguration {
return configure {
space(36.dp)
@@ -419,84 +387,6 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
when (gatewayResponse.gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.")
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
}
}
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
Log.d(TAG, "Received credit card information from fragment.")
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
}
private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) {
when (result.status) {
DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result)
DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result)
}
viewModel.refreshActiveSubscription()
}
private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge))
}
}
private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
.setPositiveButton(android.R.string.ok) { _, _ ->
findNavController().popBackStack()
}
.show()
} else {
Log.w(TAG, "Stripe action failed: ${result.action}")
}
}
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
label = gatewayResponse.request.label,
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
)
}
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
if (InAppDonations.isCreditCardAvailable()) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayResponse.request))
} else {
error("Credit cards are not currently enabled.")
}
}
private fun registerGooglePayCallback() {
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
viewModel.consumeGatewayRequestForGooglePay()?.let {
donationPaymentComponent.stripeRepository.onActivityResult(
paymentResult.requestCode,
paymentResult.resultCode,
paymentResult.data,
paymentResult.requestCode,
GooglePayRequestCallback(it)
)
}
}
)
}
private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
@@ -543,29 +433,19 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
}
inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
stripePaymentViewModel.providePaymentData(paymentData)
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, request))
}
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true)
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}
val error = DonationError.getGooglePayRequestTokenError(
source = when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
},
throwable = googlePayException
)
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
}
DonationError.routeDonationError(requireContext(), error)
}
override fun onCancelled() {
Log.d(TAG, "Cancelled Google Pay.", true)
}
override fun onProcessorActionProcessed() {
viewModel.refreshActiveSubscription()
}
}

View File

@@ -21,48 +21,56 @@ data class DonateToSignalState(
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val badge: Badge?
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canSetCurrency: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val selectedCurrency: Currency
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val selectableCurrencyCodes: List<String>
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes
DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val level: Int
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> 1
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canContinue: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canUpdate: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
data class OneTimeDonationState(

View File

@@ -6,5 +6,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize
enum class DonateToSignalType(val requestCode: Short) : Parcelable {
ONE_TIME(16141),
MONTHLY(16142);
MONTHLY(16142),
GIFT(16143)
}

View File

@@ -20,16 +20,15 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.manage.Su
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.next
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.Preconditions
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
@@ -57,8 +56,6 @@ class DonateToSignalViewModel(
private val _actions = PublishSubject.create<DonateToSignalAction>()
private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
private var gatewayRequest: GatewayRequest? = null
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
@@ -120,7 +117,15 @@ class DonateToSignalViewModel(
}
fun toggleDonationType() {
store.update { it.copy(donateToSignalType = it.donateToSignalType.next()) }
store.update {
it.copy(
donateToSignalType = when (it.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonateToSignalType.MONTHLY
DonateToSignalType.MONTHLY -> DonateToSignalType.ONE_TIME
DonateToSignalType.GIFT -> error("We are in an illegal state")
}
)
}
}
fun setSelectedSubscription(subscription: Subscription) {
@@ -178,7 +183,8 @@ class DonateToSignalViewModel(
label = snapshot.badge!!.description,
price = amount.amount,
currencyCode = amount.currency.currencyCode,
level = snapshot.level.toLong()
level = snapshot.level.toLong(),
recipientId = Recipient.self().id
)
}
@@ -186,6 +192,7 @@ class DonateToSignalViewModel(
return when (snapshot.donateToSignalType) {
DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState)
DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost()
DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.")
}
}
@@ -348,18 +355,6 @@ class DonateToSignalViewModel(
store.dispose()
}
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
Log.d(TAG, "Provided with a gateway request.")
Preconditions.checkState(gatewayRequest == null)
gatewayRequest = request
}
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
val request = gatewayRequest
gatewayRequest = null
return request
}
class Factory(
private val startType: DonateToSignalType,
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),

View File

@@ -0,0 +1,193 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Currency
/**
* Abstracts out some common UI-level interactions between gift flow and normal donate flow.
*/
class DonationCheckoutDelegate(
private val fragment: Fragment,
private val callback: Callback
) : DefaultLifecycleObserver {
companion object {
private val TAG = Log.tag(DonationCheckoutDelegate::class.java)
}
private lateinit var donationPaymentComponent: DonationPaymentComponent
private val disposables = LifecycleDisposable()
private val viewModel: DonationCheckoutViewModel by fragment.viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by fragment.navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
donationPaymentComponent = fragment.requireListener()
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository)
}
)
init {
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
}
override fun onCreate(owner: LifecycleOwner) {
disposables.bindTo(fragment.viewLifecycleOwner)
donationPaymentComponent = fragment.requireListener()
registerGooglePayCallback()
fragment.setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle ->
val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!!
handleGatewaySelectionResponse(response)
}
fragment.setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
handleDonationProcessorActionResult(result)
}
fragment.setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
handleCreditCardResult(result)
}
}
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
when (gatewayResponse.gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.")
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
}
}
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
Log.d(TAG, "Received credit card information from fragment.")
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
callback.navigateToStripePaymentInProgress(creditCardResult.gatewayRequest)
}
private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) {
when (result.status) {
DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result)
DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result)
}
callback.onProcessorActionProcessed()
}
private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
callback.onPaymentComplete(result.request)
}
}
private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) {
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
.setPositiveButton(android.R.string.ok) { _, _ ->
fragment.findNavController().popBackStack()
}
.show()
} else {
Log.w(TAG, "Stripe action failed: ${result.action}")
}
}
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
label = gatewayResponse.request.label,
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
)
}
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
if (InAppDonations.isCreditCardAvailable()) {
callback.navigateToCreditCardForm(gatewayResponse.request)
} else {
error("Credit cards are not currently enabled.")
}
}
private fun registerGooglePayCallback() {
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
viewModel.consumeGatewayRequestForGooglePay()?.let {
donationPaymentComponent.stripeRepository.onActivityResult(
paymentResult.requestCode,
paymentResult.resultCode,
paymentResult.data,
paymentResult.requestCode,
GooglePayRequestCallback(it)
)
}
}
)
}
inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
stripePaymentViewModel.providePaymentData(paymentData)
callback.navigateToStripePaymentInProgress(request)
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true)
val error = DonationError.getGooglePayRequestTokenError(
source = when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
},
throwable = googlePayException
)
DonationError.routeDonationError(fragment.requireContext(), error)
}
override fun onCancelled() {
Log.d(TAG, "Cancelled Google Pay.", true)
}
}
interface Callback {
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
}
}

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.lifecycle.ViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.whispersystems.signalservice.api.util.Preconditions
/**
* State holder for the checkout flow when utilizing Google Pay.
*/
class DonationCheckoutViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(DonationCheckoutViewModel::class.java)
}
private var gatewayRequest: GatewayRequest? = null
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
Log.d(TAG, "Provided with a gateway request.")
Preconditions.checkState(gatewayRequest == null)
gatewayRequest = request
}
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
val request = gatewayRequest
gatewayRequest = null
return request
}
}

View File

@@ -35,6 +35,9 @@ object DonationPillToggle {
DonateToSignalType.MONTHLY -> {
presentButtons(model, binding.monthly, binding.oneTime)
}
DonateToSignalType.GIFT -> {
error("Unsupported donation type.")
}
}
}

View File

@@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.recipients.RecipientId
import java.math.BigDecimal
import java.util.Currency
@@ -15,7 +17,10 @@ data class GatewayRequest(
val label: String,
val price: BigDecimal,
val currencyCode: String,
val level: Long
val level: Long,
val recipientId: RecipientId,
val additionalMessage: String? = null
) : Parcelable {
@IgnoredOnParcel
val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode))
}

View File

@@ -62,6 +62,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
when (args.request.donateToSignalType) {
DonateToSignalType.MONTHLY -> presentMonthlyText()
DonateToSignalType.ONE_TIME -> presentOneTimeText()
DonateToSignalType.GIFT -> presentGiftText()
}
space(66.dp)
@@ -138,6 +139,25 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
private fun DSLConfiguration.presentGiftText() {
noPadTextPref(
title = DSLSettingsText.from(
getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(resources, args.request.fiat)),
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)
space(6.dp)
noPadTextPref(
title = DSLSettingsText.from(
R.string.GatewaySelectorBottomSheet__send_a_gift_badge,
DSLSettingsText.CenterModifier,
DSLSettingsText.BodyLargeModifier,
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
)
)
}
companion object {
const val REQUEST_KEY = "payment_checkout_mode"
}

View File

@@ -25,9 +25,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.api.util.Preconditions
class StripePaymentInProgressViewModel(
@@ -74,6 +72,7 @@ class StripePaymentInProgressViewModel(
val errorSource = when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource)
@@ -81,6 +80,7 @@ class StripePaymentInProgressViewModel(
return when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.GIFT -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
}
}
@@ -135,7 +135,13 @@ class StripePaymentInProgressViewModel(
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
.onErrorResumeNext {
if (it is DonationError) {
Completable.error(it)
} else {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it))
}
}
disposables += setup.andThen(setLevel).subscribeBy(
onError = { throwable ->
@@ -164,17 +170,23 @@ class StripePaymentInProgressViewModel(
Log.w(TAG, "Beginning one-time payment pipeline...", true)
val amount = request.fiat
val recipient = Recipient.self().id
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, recipient, level)
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, request.recipientId, request.level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
.flatMap { nextActionHandler(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption(amount, paymentIntent.intentId, recipient, null, level) }
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
amount,
paymentIntent.intentId,
request.recipientId,
request.additionalMessage,
request.level
)
}
}.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)

View File

@@ -513,7 +513,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { mediaRecord, isLtr ->
startActivityForResult(
MediaIntentFactory.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
MediaIntentFactory.intentFromMediaRecord(requireContext(), mediaRecord, isLtr, allMediaInRail = true),
REQUEST_CODE_RETURN_FROM_MEDIA
)
}

View File

@@ -178,9 +178,9 @@ public class ConversationAdapter
} else if (messageRecord.isUpdate()) {
return MESSAGE_TYPE_UPDATE;
} else if (messageRecord.isOutgoing()) {
return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA;
return conversationMessage.isTextOnly(context) ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA;
} else {
return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA;
return conversationMessage.isTextOnly(context) ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA;
}
}

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.security.MessageDigest;
import java.util.Collections;
@@ -120,6 +121,12 @@ public class ConversationMessage {
return styleResult.getBottomButton();
}
public boolean isTextOnly(@NonNull Context context) {
return MessageRecordUtil.isTextOnly(messageRecord, context) &&
!hasBeenQuoted() &&
getBottomButton() == null;
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/

View File

@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
@@ -80,6 +81,7 @@ class MultiselectForwardFragment :
Fragment(R.layout.multiselect_forward_fragment),
SafetyNumberBottomSheet.Callbacks,
ChooseStoryTypeBottomSheet.Callback,
GroupStoryEducationSheet.Callback,
WrapperDialogFragment.WrapperDialogFragmentCallback,
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback {
@@ -466,13 +468,21 @@ class MultiselectForwardFragment :
}
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
if (SignalStore.storyValues().userHasSeenGroupStoryEducationSheet) {
onGroupStoryEducationSheetNext()
} else {
GroupStoryEducationSheet().show(childFragmentManager, GroupStoryEducationSheet.KEY)
}
}
override fun onNewStoryClicked() {
CreateStoryFlowDialogFragment().show(parentFragmentManager, CreateStoryWithViewersFragment.REQUEST_KEY)
}
override fun onGroupStoryEducationSheetNext() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
}
override fun onWrapperDialogFragmentDismissed() {
contactSearchMediator.refresh()
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout
import org.thoughtcrime.securesms.util.FeatureFlags
class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) : AppBarLayout.Behavior(context, attributeSet) {
override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
if (type == ViewCompat.TYPE_NON_TOUCH || !FeatureFlags.chatFilters()) {
return false
} else {
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
}
}
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: AppBarLayout, target: View, type: Int) {
super.onStopNestedScroll(coordinatorLayout, child, target, type)
child.setExpanded(false, true)
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.conversationlist
/**
* Small state machine that describes moving and triggering actions
* based off pulling down the conversation filter.
*/
enum class ConversationFilterLatch {
SET,
RESET;
}

View File

@@ -30,10 +30,13 @@ import java.util.Set;
class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.ViewHolder> {
private static final int TYPE_THREAD = 1;
private static final int TYPE_ACTION = 2;
private static final int TYPE_PLACEHOLDER = 3;
private static final int TYPE_HEADER = 4;
private static final int TYPE_THREAD = 1;
private static final int TYPE_ACTION = 2;
private static final int TYPE_PLACEHOLDER = 3;
private static final int TYPE_HEADER = 4;
private static final int TYPE_EMPTY = 5;
private static final int TYPE_CLEAR_FILTER_FOOTER = 6;
private static final int TYPE_CLEAR_FILTER_EMPTY = 7;
private enum Payload {
TYPING_INDICATOR,
@@ -43,6 +46,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final OnConversationClickListener onConversationClickListener;
private final OnClearFilterClickListener onClearFilterClicked;
private ConversationSet selectedConversations = new ConversationSet();
private final Set<Long> typingSet = new HashSet<>();
@@ -50,13 +54,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull OnConversationClickListener onConversationClickListener)
@NonNull OnConversationClickListener onConversationClickListener,
@NonNull OnClearFilterClickListener onClearFilterClicked)
{
super(new ConversationDiffCallback());
this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests;
this.onConversationClickListener = onConversationClickListener;
this.onClearFilterClicked = onClearFilterClicked;
}
@Override
@@ -101,6 +107,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
} else if (viewType == TYPE_HEADER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.dsl_section_header, parent, false);
return new HeaderViewHolder(v);
} else if (viewType == TYPE_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_empty_state, parent, false);
return new HeaderViewHolder(v);
} else if (viewType == TYPE_CLEAR_FILTER_FOOTER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else if (viewType == TYPE_CLEAR_FILTER_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter_empty, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else {
throw new IllegalStateException("Unknown type! " + viewType);
}
@@ -197,8 +212,14 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
return TYPE_HEADER;
case ARCHIVED_FOOTER:
return TYPE_ACTION;
case CONVERSATION_FILTER_FOOTER:
return TYPE_CLEAR_FILTER_FOOTER;
case CONVERSATION_FILTER_EMPTY:
return TYPE_CLEAR_FILTER_EMPTY;
case THREAD:
return TYPE_THREAD;
case EMPTY:
return TYPE_EMPTY;
default:
throw new IllegalArgumentException();
}
@@ -247,9 +268,22 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
}
}
static class ClearFilterViewHolder extends RecyclerView.ViewHolder {
ClearFilterViewHolder(@NonNull View itemView, OnClearFilterClickListener listener) {
super(itemView);
itemView.findViewById(R.id.clear_filter).setOnClickListener(v -> {
listener.onClearFilterClick();
});
}
}
interface OnConversationClickListener {
void onConversationClick(@NonNull Conversation conversation);
boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view);
void onShowArchiveClick();
}
interface OnClearFilterClickListener {
void onClearFilterClick();
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversationlist;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MergeCursor;
@@ -9,9 +8,11 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagedDataSource;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
@@ -22,9 +23,9 @@ import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.Stopwatch;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
@@ -35,15 +36,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private static final String TAG = Log.tag(ConversationListDataSource.class);
protected final ThreadDatabase threadDatabase;
protected final ThreadDatabase threadDatabase;
protected final ConversationFilter conversationFilter;
protected ConversationListDataSource(@NonNull Context context) {
this.threadDatabase = SignalDatabase.threads();
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
this.threadDatabase = SignalDatabase.threads();
this.conversationFilter = conversationFilter;
}
public static ConversationListDataSource create(@NonNull Context context, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(context);
else return new ArchivedConversationListDataSource(context);
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter);
else return new ArchivedConversationListDataSource(conversationFilter);
}
@Override
@@ -51,13 +54,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
long startTime = System.currentTimeMillis();
int count = getTotalCount();
Log.d(TAG, "[size(), " + getClass().getSimpleName() + "] " + (System.currentTimeMillis() - startTime) + " ms");
return count;
if (conversationFilter != ConversationFilter.OFF) {
count += 1;
}
Log.d(TAG, "[size(), " + getClass().getSimpleName() + ", " + conversationFilter + "] " + (System.currentTimeMillis() - startTime) + " ms");
return Math.max(1, count);
}
@Override
public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName());
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter);
List<Conversation> conversations = new ArrayList<>(length);
List<Recipient> recipients = new LinkedList<>();
@@ -89,7 +96,15 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
stopwatch.stop(TAG);
return conversations;
if (conversations.isEmpty() && start == 0 && length == 1) {
if (conversationFilter == ConversationFilter.OFF) {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.EMPTY, 0)));
} else {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.CONVERSATION_FILTER_EMPTY, 0)));
}
} else {
return conversations;
}
}
@Override
@@ -107,18 +122,31 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private static class ArchivedConversationListDataSource extends ConversationListDataSource {
ArchivedConversationListDataSource(@NonNull Context context) {
super(context);
private int totalCount;
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(conversationFilter);
}
@Override
protected int getTotalCount() {
return threadDatabase.getArchivedConversationListCount();
totalCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
return totalCount;
}
@Override
protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getArchivedConversationList(offset, limit);
List<Cursor> cursors = new ArrayList<>(2);
Cursor cursor = threadDatabase.getArchivedConversationList(conversationFilter, offset, limit);
cursors.add(cursor);
if (offset + limit >= totalCount && totalCount > 0 && conversationFilter != ConversationFilter.OFF) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
cursors.add(conversationFilterFooter);
}
return new MergeCursor(cursors.toArray(new Cursor[]{}));
}
}
@@ -130,16 +158,16 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int archivedCount;
private int unpinnedCount;
UnarchivedConversationListDataSource(@NonNull Context context) {
super(context);
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(conversationFilter);
}
@Override
protected int getTotalCount() {
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount();
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount(conversationFilter);
pinnedCount = threadDatabase.getPinnedConversationListCount();
archivedCount = threadDatabase.getArchivedConversationListCount();
pinnedCount = threadDatabase.getPinnedConversationListCount(conversationFilter);
archivedCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
unpinnedCount = unarchivedCount - pinnedCount;
totalCount = unarchivedCount;
@@ -170,7 +198,7 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
limit--;
}
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit);
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, true, offset, limit);
cursors.add(pinnedCursor);
limit -= pinnedCursor.getCount();
@@ -182,15 +210,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
}
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit);
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
cursors.add(unpinnedCursor);
if (offset + originalLimit >= totalCount && hasArchivedFooter()) {
boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
boolean shouldInsertArchivedFooter = offset + originalLimit >= totalCount - (shouldInsertConversationFilterFooter ? 1 : 0) && hasArchivedFooter();
if (shouldInsertArchivedFooter) {
MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS);
archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount));
cursors.add(archivedFooterCursor);
}
if (shouldInsertConversationFilterFooter) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
cursors.add(conversationFilterFooter);
}
return new MergeCursor(cursors.toArray(new Cursor[]{}));
}
@@ -213,5 +249,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
boolean hasArchivedFooter() {
return archivedCount != 0;
}
boolean hasConversationFilterFooter() {
return totalCount > 1 && conversationFilter != ConversationFilter.OFF;
}
}
}

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
/**
* Encapsulates the push / pull latch for enabling and disabling
* filters into a convenient view.
*/
class ConversationListFilterPullView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val colorPull = ContextCompat.getColor(context, R.color.signal_colorSurface1)
private val colorRelease = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
private var state: State = State.PULL
init {
inflate(context, R.layout.conversation_list_filter_pull_view, this)
setBackgroundColor(colorPull)
}
private val binding = ConversationListFilterPullViewBinding.bind(this)
fun setToPull() {
if (state == State.PULL) {
return
}
state = State.PULL
setBackgroundColor(colorPull)
binding.arrow.setImageResource(R.drawable.ic_arrow_down)
binding.text.setText(R.string.ConversationListFilterPullView__pull_down_to_filter)
}
fun setToRelease() {
if (state == State.RELEASE) {
return
}
if (Settings.System.getInt(context.contentResolver, Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP)
}
state = State.RELEASE
setBackgroundColor(colorRelease)
binding.arrow.setImageResource(R.drawable.ic_arrow_up_16)
binding.text.setText(R.string.ConversationListFilterPullView__release_to_filter)
}
enum class State {
RELEASE,
PULL
}
}

View File

@@ -22,6 +22,7 @@ import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
@@ -40,7 +41,6 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
@@ -68,6 +68,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.airbnb.lottie.SimpleColorFilter;
import com.annimon.stream.Stream;
import com.google.android.material.animation.ArgbEvaluatorCompat;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
@@ -136,8 +137,6 @@ import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -153,6 +152,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -191,7 +191,7 @@ import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener,
MegaphoneActionController
MegaphoneActionController, ConversationListAdapter.OnClearFilterClickListener
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short SMS_ROLE_REQUEST_CODE = 32563;
@@ -207,8 +207,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private RecyclerView list;
private Stub<ReminderView> reminderView;
private Stub<UnreadPaymentsView> paymentNotificationView;
private Stub<ViewGroup> emptyState;
private TextView searchEmptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private ConversationListViewModel viewModel;
@@ -263,10 +261,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
coordinator = view.findViewById(R.id.coordinator);
list = view.findViewById(R.id.list);
searchEmptyState = view.findViewById(R.id.search_no_results);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
reminderView = new Stub<>(view.findViewById(R.id.reminder));
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
@@ -276,6 +272,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
fab.setVisibility(View.VISIBLE);
cameraFab.setVisibility(View.VISIBLE);
ConversationListFilterPullView pullView = view.findViewById(R.id.pull_view);
AppBarLayout appBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
if (verticalOffset == 0) {
viewModel.setConversationFilterLatch(ConversationFilterLatch.SET);
pullView.setToRelease();
} else if (verticalOffset == -layout.getHeight()) {
viewModel.setConversationFilterLatch(ConversationFilterLatch.RESET);
pullView.setToPull();
}
});
fab.show();
cameraFab.show();
@@ -345,10 +354,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onDestroyView() {
coordinator = null;
list = null;
searchEmptyState = null;
bottomActionBar = null;
reminderView = null;
emptyState = null;
megaphoneContainer = null;
paymentNotificationView = null;
voiceNotePlayerViewStub = null;
@@ -465,6 +472,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.menu_insights).setVisible(Util.isDefaultSmsProvider(requireContext()));
menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(requireContext()));
menu.findItem(R.id.menu_filter_unread_chats).setVisible(FeatureFlags.chatFilters());
}
@Override
@@ -486,11 +494,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
handleInsights(); return true;
case R.id.menu_notification_profile:
handleNotificationProfile(); return true;
case R.id.menu_filter_unread_chats:
handleFilterUnreadChats(); return true;
}
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onMegaphoneChanged(viewModel.getMegaphone().getValue());
}
private boolean isSearchOpen() {
return isSearchVisible() || activeAdapter == searchAdapter;
}
@@ -645,23 +661,27 @@ public class ConversationListFragment extends MainFragment implements ActionMode
viewModel.onSearchQueryUpdated(trimmed);
if (trimmed.length() > 0) {
if (activeAdapter != searchAdapter) {
if (activeAdapter != searchAdapter && list != null) {
setAdapter(searchAdapter);
list.removeItemDecoration(searchAdapterDecoration);
list.addItemDecoration(searchAdapterDecoration);
}
} else {
if (activeAdapter != defaultAdapter) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
if (list != null) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
}
}
}
@Override
public void onSearchClosed() {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
if (list != null) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
requireCallback().onSearchClosed();
fadeInButtonsAndMegaphone(250);
}
@@ -691,7 +711,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this);
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this);
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault());
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0);
@@ -727,7 +747,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
if (adapter instanceof ConversationListAdapter) {
((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController());
viewModel.getPagingController()
.observe(getViewLifecycleOwner(),
controller -> ((ConversationListAdapter) adapter).setPagingController(controller));
}
list.setAdapter(adapter);
@@ -826,17 +848,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void onSearchResultChanged(@Nullable SearchResult result) {
result = result != null ? result : SearchResult.EMPTY;
searchAdapter.updateResults(result);
if (result.isEmpty() && activeAdapter == searchAdapter) {
searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery()));
searchEmptyState.setVisibility(View.VISIBLE);
} else {
searchEmptyState.setVisibility(View.GONE);
}
}
private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
if (megaphone == null || isArchived()) {
if (megaphone == null || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (megaphoneContainer.resolved()) {
megaphoneContainer.get().setVisibility(View.GONE);
megaphoneContainer.get().removeAllViews();
@@ -966,6 +981,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
NotificationProfileSelectionFragment.show(getParentFragmentManager());
}
private void handleFilterUnreadChats() {
viewModel.toggleUnreadChatsFilter();
}
@SuppressLint("StaticFieldLeak")
private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) {
Set<Long> selectedConversations = new HashSet<>(ids);
@@ -1173,21 +1192,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
void updateEmptyState(boolean isConversationEmpty) {
if (isConversationEmpty) {
Log.i(TAG, "Received an empty data set.");
list.setVisibility(View.INVISIBLE);
emptyState.get().setVisibility(View.VISIBLE);
fab.startPulse(3 * 1000);
cameraFab.startPulse(3 * 1000);
SignalStore.onboarding().setShowNewGroup(true);
SignalStore.onboarding().setShowInviteFriends(true);
} else {
list.setVisibility(View.VISIBLE);
fab.stopPulse();
cameraFab.stopPulse();
if (emptyState.resolved()) {
emptyState.get().setVisibility(View.GONE);
}
}
}
@@ -1456,6 +1468,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}.executeOnExecutor(SignalExecutors.BOUNDED, threadId);
}
@Override
public void onClearFilterClick() {
viewModel.toggleUnreadChatsFilter();
}
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
private final UnreadPayments unreadPayments;

View File

@@ -22,9 +22,12 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import java.util.Collections;
import java.util.Locale;
class ConversationListSearchAdapter extends RecyclerView.Adapter<ConversationListSearchAdapter.SearchResultViewHolder>
class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationListSearchAdapter.HeaderViewHolder>
{
private static final int VIEW_TYPE_EMPTY = 0;
private static final int VIEW_TYPE_NON_EMPTY = 1;
private static final int TYPE_CONVERSATIONS = 1;
private static final int TYPE_CONTACTS = 2;
private static final int TYPE_MESSAGES = 3;
@@ -49,47 +52,69 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
}
@Override
public @NonNull SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
}
@Override
public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) {
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
holder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
holder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
holder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_EMPTY) {
return new EmptyViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_empty_search_state, parent, false));
} else {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
}
}
@Override
public void onViewRecycled(@NonNull SearchResultViewHolder holder) {
holder.recycle();
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof SearchResultViewHolder) {
SearchResultViewHolder viewHolder = (SearchResultViewHolder) holder;
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
viewHolder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
viewHolder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
viewHolder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
}
} else if (holder instanceof EmptyViewHolder) {
EmptyViewHolder viewHolder = (EmptyViewHolder) holder;
viewHolder.bind(searchResult.getQuery());
}
}
@Override
public int getItemViewType(int position) {
if (searchResult.isEmpty()) {
return VIEW_TYPE_EMPTY;
} else {
return VIEW_TYPE_NON_EMPTY;
}
}
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof SearchResultViewHolder) {
((SearchResultViewHolder) holder).recycle();
}
}
@Override
public int getItemCount() {
return searchResult.size();
return searchResult.isEmpty() ? 1 : searchResult.size();
}
@Override
public long getHeaderId(int position) {
if (position < 0) {
if (position < 0 || searchResult.isEmpty()) {
return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID;
} else if (getConversationResult(position) != null) {
return TYPE_CONVERSATIONS;
@@ -154,6 +179,20 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
void onMessageClicked(@NonNull MessageResult message);
}
static class EmptyViewHolder extends RecyclerView.ViewHolder {
private final TextView textView;
public EmptyViewHolder(@NonNull View itemView) {
super(itemView);
textView = itemView.findViewById(R.id.search_no_results);
}
public void bind(@NonNull String query) {
textView.setText(textView.getContext().getString(R.string.SearchFragment_no_results, query));
}
}
static class SearchResultViewHolder extends RecyclerView.ViewHolder {
private final ConversationListItem root;

View File

@@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.conversationlist;
import android.app.Application;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -17,6 +17,7 @@ import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
@@ -40,6 +41,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -50,6 +52,7 @@ import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Pair;
class ConversationListViewModel extends ViewModel {
@@ -57,30 +60,32 @@ class ConversationListViewModel extends ViewModel {
private static boolean coldStart = true;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final Set<Conversation> internalSelection;
private final ConversationListDataSource conversationListDataSource;
private final LivePagedData<Long, Conversation> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final CompositeDisposable disposables;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final MutableLiveData<ConversationFilter> conversationFilter;
private final LiveData<ConversationListDataSource> conversationListDataSource;
private final Set<Conversation> internalSelection;
private final LiveData<LivePagedData<Long, Conversation>> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final CompositeDisposable disposables;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private String activeQuery;
private SearchResult activeSearchResult;
private int pinnedCount;
private String activeQuery;
private SearchResult activeSearchResult;
private int pinnedCount;
private ConversationFilterLatch conversationFilterLatch;
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.internalSelection = new HashSet<>();
@@ -95,29 +100,37 @@ class ConversationListViewModel extends ViewModel {
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationListDataSource = ConversationListDataSource.create(application, isArchived);
this.pagedData = PagedData.createForLiveData(conversationListDataSource,
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build());
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
this.conversationFilterLatch = ConversationFilterLatch.RESET;
this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived));
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build()));
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
updateDebouncer.publish(() -> {
if (!TextUtils.isEmpty(activeQuery)) {
onSearchQueryUpdated(activeQuery);
}
pagedData.getController().onDataInvalidated();
LivePagedData<Long, Conversation> data = pagedData.getValue();
if (data == null) {
return;
}
data.getController().onDataInvalidated();
});
};
this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> {
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount();
this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilter, getConversationList(), Pair::new), filterAndData -> {
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF);
if (conversations.size() > 0) {
if (filterAndData.getSecond().size() > 0) {
return false;
} else {
return SignalDatabase.threads().getArchivedConversationListCount() == 0;
return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst()) == 0;
}
});
@@ -137,11 +150,11 @@ class ConversationListViewModel extends ViewModel {
}
@NonNull LiveData<List<Conversation>> getConversationList() {
return pagedData.getData();
return Transformations.switchMap(pagedData, LivePagedData::getData);
}
@NonNull PagingController getPagingController() {
return pagedData.getController();
@NonNull LiveData<PagingController<Long>> getPagingController() {
return Transformations.map(pagedData, LivePagedData::getController);
}
@NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() {
@@ -199,6 +212,25 @@ class ConversationListViewModel extends ViewModel {
setSelection(newSelection);
}
void setConversationFilterLatch(@NonNull ConversationFilterLatch latch) {
ConversationFilterLatch previous = conversationFilterLatch;
conversationFilterLatch = latch;
if (previous != latch && latch == ConversationFilterLatch.RESET) {
toggleUnreadChatsFilter();
}
}
public void toggleUnreadChatsFilter() {
ConversationFilter filter = Objects.requireNonNull(conversationFilter.getValue());
if (filter == ConversationFilter.UNREAD) {
Log.d(TAG, "Setting filter to OFF");
conversationFilter.setValue(ConversationFilter.OFF);
} else {
Log.d(TAG, "Setting filter to UNREAD");
conversationFilter.setValue(ConversationFilter.UNREAD);
}
}
private void setSelection(@NonNull Collection<Conversation> newSelection) {
internalSelection.clear();
internalSelection.addAll(newSelection);
@@ -206,8 +238,13 @@ class ConversationListViewModel extends ViewModel {
}
void onSelectAllClick() {
ConversationListDataSource dataSource = conversationListDataSource.getValue();
if (dataSource == null) {
return;
}
disposables.add(
Single.fromCallable(() -> conversationListDataSource.load(0, conversationListDataSource.size(), disposables::isDisposed))
Single.fromCallable(() -> dataSource.load(0, dataSource.size(), disposables::isDisposed))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setSelection)
@@ -301,7 +338,7 @@ class ConversationListViewModel extends ViewModel {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(noteToSelfTitle), isArchived));
return modelClass.cast(new ConversationListViewModel(new SearchRepository(noteToSelfTitle), isArchived));
}
}
}

View File

@@ -42,6 +42,9 @@ public class Conversation {
THREAD,
PINNED_HEADER,
UNPINNED_HEADER,
ARCHIVED_FOOTER
ARCHIVED_FOOTER,
CONVERSATION_FILTER_FOOTER,
CONVERSATION_FILTER_EMPTY,
EMPTY
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.conversationlist.model
/**
* Describes what conversations should display in the
* conversation list.
*/
enum class ConversationFilter {
/**
* No filtering is applied to the conversation list
*/
OFF,
/**
* Only unread chats will be displayed in the conversation list
*/
UNREAD,
/**
* Only muted chats will be displayed in the conversation list
*/
MUTED,
/**
* Only group chats will be displayed in the conversation list
*/
GROUPS
}

View File

@@ -12,10 +12,11 @@ import org.signal.core.util.CursorUtil;
public class ConversationReader extends ThreadDatabase.StaticReader {
public static final String[] HEADER_COLUMN = {"header"};
public static final String[] ARCHIVED_COLUMNS = {"header", "count"};
public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()};
public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()};
public static final String[] HEADER_COLUMN = { "header" };
public static final String[] ARCHIVED_COLUMNS = { "header", "count" };
public static final String[] PINNED_HEADER = { Conversation.Type.PINNED_HEADER.toString() };
public static final String[] UNPINNED_HEADER = { Conversation.Type.UNPINNED_HEADER.toString() };
public static final String[] CONVERSATION_FILTER_FOOTER = { Conversation.Type.CONVERSATION_FILTER_FOOTER.toString() };
private final Cursor cursor;
@@ -43,11 +44,16 @@ public class ConversationReader extends ThreadDatabase.StaticReader {
if (type == Conversation.Type.ARCHIVED_FOOTER) {
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
}
return buildThreadRecordForType(type, count);
}
public static ThreadRecord buildThreadRecordForType(@NonNull Conversation.Type type, int count) {
return new ThreadRecord.Builder(-(100 + type.ordinal()))
.setBody(type.toString())
.setDate(100)
.setRecipient(Recipient.UNKNOWN)
.setUnreadCount(count)
.build();
.setBody(type.toString())
.setDate(100)
.setRecipient(Recipient.UNKNOWN)
.setUnreadCount(count)
.build();
}
}

View File

@@ -417,6 +417,20 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns,
return 0;
}
/**
* Resets the exported state and exported flag so messages can be re-exported.
*/
public void clearExportState() {
ContentValues values = new ContentValues(2);
values.putNull(EXPORT_STATE);
values.put(EXPORTED, MessageExportStatus.UNEXPORTED.serialize());
SQLiteDatabaseExtensionsKt.update(getWritableDatabase(), getTableName())
.values(values)
.where(EXPORT_STATE + " IS NOT NULL OR " + EXPORTED + " != ?", MessageExportStatus.UNEXPORTED)
.run();
}
/**
* Reset the exported status (not state) to the default for clearing errors.
*/

View File

@@ -440,7 +440,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
return getAndPossiblyMerge(serviceId = serviceId, pni = pni, e164 = e164, pniVerified = true, changeSelf = false)
}
private fun getAndPossiblyMerge(serviceId: ServiceId?, pni: PNI?, e164: String?, pniVerified: Boolean = false, changeSelf: Boolean = false): RecipientId {
@VisibleForTesting
fun getAndPossiblyMerge(serviceId: ServiceId?, pni: PNI?, e164: String?, pniVerified: Boolean = false, changeSelf: Boolean = false): RecipientId {
require(!(serviceId == null && e164 == null)) { "Must provide an ACI or E164!" }
if ((serviceId is PNI) && pni != null && serviceId != pni) {

View File

@@ -24,6 +24,7 @@ import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts
@@ -132,7 +133,8 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
val CREATE_INDEXS = arrayOf(
"CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);",
"CREATE INDEX IF NOT EXISTS archived_count_index ON $TABLE_NAME ($ARCHIVED, $MEANINGFUL_MESSAGES);",
"CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);"
"CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);",
"CREATE INDEX IF NOT EXISTS thread_read ON $TABLE_NAME ($READ);"
)
private val THREAD_PROJECTION = arrayOf(
@@ -754,7 +756,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
}
fun getArchivedRecipients(): Set<RecipientId> {
return getArchivedConversationList().readToList { cursor ->
return getArchivedConversationList(ConversationFilter.OFF).readToList { cursor ->
RecipientId.from(cursor.requireLong(RECIPIENT_ID))
}.toSet()
}
@@ -775,16 +777,18 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return positions
}
fun getArchivedConversationList(offset: Long = 0, limit: Long = 0): Cursor {
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0", offset, limit, preferPinned = false)
fun getArchivedConversationList(conversationFilter: ConversationFilter, offset: Long = 0, limit: Long = 0): Cursor {
val filterQuery = conversationFilter.toQuery()
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0 $filterQuery", offset, limit, preferPinned = false)
return readableDatabase.rawQuery(query, arrayOf("1"))
}
fun getUnarchivedConversationList(pinned: Boolean, offset: Long, limit: Long): Cursor {
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
val filterQuery = conversationFilter.toQuery()
val where = if (pinned) {
"$ARCHIVED = 0 AND $PINNED != 0"
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
} else {
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0"
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
}
val query = if (pinned) {
@@ -796,11 +800,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return readableDatabase.rawQuery(query, null)
}
fun getArchivedConversationListCount(): Int {
fun getArchivedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0")
.where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0 $filterQuery")
.run()
.use { cursor ->
if (cursor.moveToFirst()) {
@@ -811,11 +816,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
}
}
fun getPinnedConversationListCount(): Int {
fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ARCHIVED = 0 AND $PINNED != 0")
.where("$ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
.run()
.use { cursor ->
if (cursor.moveToFirst()) {
@@ -826,11 +832,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
}
}
fun getUnarchivedConversationListCount(): Int {
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)")
.where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
.run()
.use { cursor ->
if (cursor.moveToFirst()) {
@@ -888,7 +895,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
.run()
}
var pinnedCount = getPinnedConversationListCount()
var pinnedCount = getPinnedConversationListCount(ConversationFilter.OFF)
for (threadId in threadIds) {
pinnedCount++
@@ -1587,6 +1594,15 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
return this.trimIndent().split("\n").joinToString(separator = " ")
}
private fun ConversationFilter.toQuery(): String {
return when (this) {
ConversationFilter.OFF -> ""
ConversationFilter.UNREAD -> " AND $READ != ${ReadStatus.READ.serialize()}"
ConversationFilter.MUTED -> error("This filter selection isn't supported yet.")
ConversationFilter.GROUPS -> error("This filter selection isn't supported yet.")
}
}
object DistributionTypes {
const val DEFAULT = 2
const val BROADCAST = 1

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V160_SmsMmsExported
import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex
import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup
import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration
import org.thoughtcrime.securesms.database.helpers.migration.V164_ThreadDatabaseReadIndexMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@@ -27,7 +28,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 163
const val DATABASE_VERSION = 164
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -90,6 +91,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 163) {
V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < 164) {
V164_ThreadDatabaseReadIndexMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
object V164_ThreadDatabaseReadIndexMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("CREATE INDEX IF NOT EXISTS thread_read ON thread (read);")
}
}

View File

@@ -132,10 +132,12 @@ class SignalSmsExportReader(
}
private fun readExportableMmsMessageFromRecord(record: MessageRecord, exportState: MessageExportState): ExportableMessage {
val self = Recipient.self()
val threadRecipient: Recipient? = SignalDatabase.threads.getRecipientForThreadId(record.threadId)
val addresses: Set<String> = if (threadRecipient?.isMmsGroup == true) {
Recipient
.resolvedList(threadRecipient.participantIds)
.filter { it != self }
.map { r -> r.smsExportAddress() }
.toSet()
} else if (threadRecipient != null) {

View File

@@ -31,8 +31,10 @@ class SignalSmsExportService : SmsExportService() {
/**
* Launches the export service and immediately begins exporting messages.
*/
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, SignalSmsExportService::class.java))
fun start(context: Context, clearPreviousExportState: Boolean) {
val intent = Intent(context, SignalSmsExportService::class.java)
.apply { putExtra(CLEAR_PREVIOUS_EXPORT_STATE_EXTRA, clearPreviousExportState) }
ContextCompat.startForegroundService(context, intent)
}
}
@@ -80,6 +82,11 @@ class SignalSmsExportService : SmsExportService() {
)
}
override fun clearPreviousExportState() {
SignalDatabase.sms.clearExportState()
SignalDatabase.mms.clearExportState()
}
override fun prepareForExport() {
SignalDatabase.sms.clearInsecureMessageExportedErrorStatus()
SignalDatabase.mms.clearInsecureMessageExportedErrorStatus()

View File

@@ -8,6 +8,7 @@ import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -28,6 +29,8 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
*/
class ExportingSmsMessagesFragment : Fragment(R.layout.exporting_sms_messages_fragment) {
private val viewModel: SmsExportViewModel by activityViewModels()
private val lifecycleDisposable = LifecycleDisposable()
private var navigationDisposable = Disposable.disposed()
@@ -109,7 +112,7 @@ class ExportingSmsMessagesFragment : Fragment(R.layout.exporting_sms_messages_fr
.request(Manifest.permission.READ_SMS)
.ifNecessary()
.withRationaleDialog(getString(R.string.ExportingSmsMessagesFragment__signal_needs_the_sms_permission_to_be_able_to_export_your_sms_messages), R.drawable.ic_messages_solid_24)
.onAllGranted { SignalSmsExportService.start(requireContext()) }
.onAllGranted { SignalSmsExportService.start(requireContext(), viewModel.isReExport) }
.withPermanentDenialDialog(getString(R.string.ExportingSmsMessagesFragment__signal_needs_the_sms_permission_to_be_able_to_export_your_sms_messages)) { requireActivity().finish() }
.onAnyDenied { checkPermissionsAndStartExport() }
.execute()

View File

@@ -6,6 +6,7 @@ import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
@@ -15,15 +16,21 @@ import org.thoughtcrime.securesms.util.WindowUtil
class SmsExportActivity : FragmentWrapperActivity() {
private lateinit var viewModel: SmsExportViewModel
override fun onResume() {
super.onResume()
WindowUtil.setLightStatusBarFromTheme(this)
NotificationManagerCompat.from(this).cancel(NotificationIds.SMS_EXPORT_COMPLETE)
}
@Suppress("ReplaceGetOrSet")
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
onBackPressedDispatcher.addCallback(this, OnBackPressed())
val factory = SmsExportViewModel.Factory(intent.getBooleanExtra(IS_RE_EXPORT, false))
viewModel = ViewModelProvider(this, factory).get(SmsExportViewModel::class.java)
}
override fun getFragment(): Fragment {
@@ -39,7 +46,14 @@ class SmsExportActivity : FragmentWrapperActivity() {
}
companion object {
const val IS_RE_EXPORT = "is_re_export"
@JvmOverloads
@JvmStatic
fun createIntent(context: Context): Intent = Intent(context, SmsExportActivity::class.java)
fun createIntent(context: Context, isReExport: Boolean = false): Intent {
return Intent(context, SmsExportActivity::class.java).apply {
putExtra(IS_RE_EXPORT, isReExport)
}
}
}
}

View File

@@ -26,4 +26,14 @@ object SmsExportDialogs {
}
.show()
}
@JvmStatic
fun showSmsReExportDialog(context: Context, continueCallback: Runnable) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ReExportSmsMessagesDialogFragment__export_sms_again)
.setMessage(R.string.ReExportSmsMessagesDialogFragment__you_already_exported_your_sms_messages)
.setPositiveButton(R.string.ReExportSmsMessagesDialogFragment__continue) { _, _ -> continueCallback.run() }
.setNegativeButton(R.string.ReExportSmsMessagesDialogFragment__cancel, null)
.show()
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.exporter.flow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
/**
* Hold shared state for the SMS export flow.
*
* Note: Will be expanded on eventually to support different behavior when entering via megaphone.
*/
class SmsExportViewModel(val isReExport: Boolean) : ViewModel() {
class Factory(private val isReExport: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SmsExportViewModel(isReExport)))
}
}
}

View File

@@ -9,6 +9,7 @@ import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -150,7 +151,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
}
if (!values.hasMetConversationRequirement) {
if ((SignalDatabase.threads.getArchivedConversationListCount() + SignalDatabase.threads.getUnarchivedConversationListCount()) < 6) {
if ((SignalDatabase.threads.getArchivedConversationListCount(ConversationFilter.OFF) + SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF)) < 6) {
Log.i(TAG, "User does not have enough conversations to show release channel")
values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY
return

View File

@@ -18,7 +18,7 @@ private const val MINIMUM_QUERY_THRESHOLD = 1
private const val MINIMUM_INLINE_QUERY_THRESHOLD = 2
private const val EMOJI_SEARCH_LIMIT = 20
private val NOT_PUNCTUATION = "[A-Za-z0-9 ]".toRegex()
private val NOT_PUNCTUATION = "[^\\p{Punct}]".toRegex()
class EmojiSearchRepository(private val context: Context) {

View File

@@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -28,8 +29,7 @@ public final class MiscellaneousValues extends SignalStoreValues {
private static final String LAST_FCM_FOREGROUND_TIME = "misc.last_fcm_foreground_time";
private static final String LAST_FOREGROUND_TIME = "misc.last_foreground_time";
private static final String PNI_INITIALIZED_DEVICES = "misc.pni_initialized_devices";
private static final String SMS_PHASE_1_START_MS = "misc.sms_export.phase_1_start.2";
private static final String STORIES_FEATURE_AVAILABLE_MS = "misc.stories_feature_available_ms";
private static final String SMS_PHASE_1_START_MS = "misc.sms_export.phase_1_start.3";
MiscellaneousValues(@NonNull KeyValueStore store) {
super(store);
@@ -42,10 +42,7 @@ public final class MiscellaneousValues extends SignalStoreValues {
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Arrays.asList(
SMS_PHASE_1_START_MS,
STORIES_FEATURE_AVAILABLE_MS
);
return Collections.singletonList(SMS_PHASE_1_START_MS);
}
public long getLastPrekeyRefreshTime() {
@@ -234,20 +231,7 @@ public final class MiscellaneousValues extends SignalStoreValues {
}
}
public long getStoriesFeatureAvailableTimestamp() {
return getLong(STORIES_FEATURE_AVAILABLE_MS, 0);
}
public void setStoriesFeatureAvailableTimestamp(long timestamp) {
putLong(STORIES_FEATURE_AVAILABLE_MS, timestamp);
}
public @NonNull SmsExportPhase getSmsExportPhase() {
if (getLong(SMS_PHASE_1_START_MS, 0) == 0) {
return SmsExportPhase.PHASE_0;
}
long now = System.currentTimeMillis();
return SmsExportPhase.getCurrentPhase(now - getLong(SMS_PHASE_1_START_MS, now));
return SmsExportPhase.PHASE_0;
}
}

View File

@@ -49,6 +49,11 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
* Whether or not the user will send and receive viewed receipts for stories
*/
private const val STORY_VIEWED_RECEIPTS = "stories.viewed.receipts"
/**
* Whether or not the user has seen the group story education sheet
*/
private const val USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET = "stories.user.has.seen.group.story.education.sheet"
}
override fun onFirstEverAppLaunch() {
@@ -62,7 +67,8 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
HAS_DOWNLOADED_ONBOARDING_STORY,
USER_HAS_VIEWED_ONBOARDING_STORY,
USER_HAS_READ_ONBOARDING_STORY,
STORY_VIEWED_RECEIPTS
STORY_VIEWED_RECEIPTS,
USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET
)
var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false)
@@ -81,6 +87,8 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
var viewedReceiptsEnabled: Boolean by booleanValue(STORY_VIEWED_RECEIPTS, false)
var userHasSeenGroupStoryEducationSheet: Boolean by booleanValue(USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET, false)
fun isViewedReceiptsStateSet(): Boolean {
return store.containsKey(STORY_VIEWED_RECEIPTS)
}

View File

@@ -57,7 +57,7 @@ public class LogSectionSystemInfo implements LogSection {
.append(ScreenDensity.get(context)).append(", ")
.append(getScreenRefreshRate(context)).append("\n");
builder.append("Font Scale : ").append(context.getResources().getConfiguration().fontScale).append("\n");
builder.append("Animation Scale: ").append(ContextUtil.getAnimationScale(context));
builder.append("Animation Scale: ").append(ContextUtil.getAnimationScale(context)).append("\n");
builder.append("Android : ").append(Build.VERSION.RELEASE).append(", API ")
.append(Build.VERSION.SDK_INT).append(" (")
.append(Build.VERSION.INCREMENTAL).append(", ")

View File

@@ -52,7 +52,8 @@ object MediaIntentFactory {
fun intentFromMediaRecord(
context: Context,
mediaRecord: MediaRecord,
leftIsRecent: Boolean
leftIsRecent: Boolean,
allMediaInRail: Boolean
): Intent {
val attachment: DatabaseAttachment = mediaRecord.attachment!!
return create(
@@ -65,7 +66,7 @@ object MediaIntentFactory {
attachment.size,
attachment.caption,
leftIsRecent,
allMediaInRail = true,
allMediaInRail = allMediaInRail,
sorting = MediaDatabase.Sorting.Newest,
isVideoGif = attachment.isVideoGif
)

View File

@@ -17,6 +17,7 @@ import com.airbnb.lottie.model.KeyPath
import com.google.android.exoplayer2.ui.PlayerControlView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.visible
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -61,7 +62,7 @@ class MediaPreviewPlayerControlView @JvmOverloads constructor(
@SuppressLint("SetTextI18n")
fun setMediaMode(mediaMode: MediaMode) {
durationBar.visibility = if (mediaMode == MediaMode.VIDEO) VISIBLE else GONE
durationBar.visible = mediaMode == MediaMode.VIDEO
videoControls.visibility = if (mediaMode == MediaMode.VIDEO) VISIBLE else INVISIBLE
if (mediaMode == MediaMode.VIDEO) {
setProgressUpdateListener { position, _ ->

View File

@@ -4,6 +4,7 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.util.MediaUtil
class MediaPreviewV2Adapter(val fragment: Fragment) : FragmentStateAdapter(fragment) {
@@ -37,6 +38,10 @@ class MediaPreviewV2Adapter(val fragment: Fragment) : FragmentStateAdapter(fragm
return fragment
}
fun findItemPosition(media: Media): Int {
return items.indexOfFirst { it.uri == media.uri }
}
fun updateBackingItems(newItems: Collection<Attachment>) {
if (newItems != items) {
items = newItems.toList()

View File

@@ -24,7 +24,8 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.MarginPageTransformer
import androidx.viewpager2.widget.ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT
@@ -42,7 +43,9 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.databinding.FragmentMediaPreviewV2Binding
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.ImageLoadingListener
import org.thoughtcrime.securesms.mediapreview.mediarail.CenterDecoration
import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter
import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter.ImageLoadingListener
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mms.GlideApp
@@ -137,20 +140,11 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
private fun initializeAlbumRail() {
binding.mediaPreviewPlaybackControls.recyclerView.apply {
this.itemAnimator = null // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682
PagerSnapHelper().attachToRecyclerView(this)
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
addItemDecoration(CenterDecoration(0))
albumRailAdapter = MediaRailAdapter(
GlideApp.with(this@MediaPreviewV2Fragment),
object : MediaRailAdapter.RailItemListener {
override fun onRailItemClicked(distanceFromActive: Int) {
binding.mediaPager.currentItem += distanceFromActive
}
override fun onRailItemDeleteClicked(distanceFromActive: Int) {
throw UnsupportedOperationException("Callback unsupported.")
}
},
false,
{ media -> jumpViewPagerToMedia(media) },
object : ImageLoadingListener() {
override fun onAllRequestsFinished() {
crossfadeViewIn(this@apply)
@@ -281,22 +275,53 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
if (albumRail.visibility == GONE) {
albumRail.visibility = View.INVISIBLE
}
albumRailAdapter.setMedia(albumThumbnailMedia, albumPosition)
albumRail.smoothScrollToPosition(albumPosition)
albumRailAdapter.currentItemPosition = albumPosition
albumRailAdapter.submitList(albumThumbnailMedia)
scrollAlbumRailToCurrentAdapterPosition()
} else {
albumRail.visibility = View.GONE
albumRailAdapter.setMedia(emptyList())
albumRailAdapter.submitList(emptyList())
albumRailAdapter.imageLoadingListener.reset()
}
}
private fun scrollAlbumRailToCurrentAdapterPosition() {
val currentItemPosition = albumRailAdapter.currentItemPosition
val currentList = albumRailAdapter.currentList
val albumRail: RecyclerView = binding.mediaPreviewPlaybackControls.recyclerView
var selectedItemWidth = -1
for (i in currentList.indices) {
val isSelected = i == currentItemPosition
val stableId = albumRailAdapter.getItemId(i)
val viewHolder = albumRail.findViewHolderForItemId(stableId) as? MediaRailAdapter.MediaRailViewHolder
if (viewHolder != null) {
viewHolder.setSelectedItem(isSelected)
if (isSelected) {
selectedItemWidth = viewHolder.itemView.width
}
}
}
val offsetFromStart = (albumRail.width - selectedItemWidth) / 2
val smoothScroller = OffsetSmoothScroller(requireContext(), offsetFromStart)
smoothScroller.targetPosition = currentItemPosition
val layoutManager = albumRail.layoutManager as LinearLayoutManager
layoutManager.startSmoothScroll(smoothScroller)
}
private fun crossfadeViewIn(view: View, duration: Long = 200) {
if (!view.isVisible) {
if (!view.isVisible && !fullscreenHelper.isSystemUiVisible) {
val viewPropertyAnimator = view.animate()
.alpha(1f)
.setDuration(duration)
.withStartAction {
view.visibility = VISIBLE
}
.withEndAction {
if (view == binding.mediaPreviewPlaybackControls.recyclerView) {
scrollAlbumRailToCurrentAdapterPosition()
}
}
if (Build.VERSION.SDK_INT >= 21) {
viewPropertyAnimator.interpolator = PathInterpolator(0.17f, 0.17f, 0f, 1f)
}
@@ -306,6 +331,12 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
private fun getMediaPreviewFragmentFromChildFragmentManager(currentPosition: Int) = childFragmentManager.findFragmentByTag("f$currentPosition") as? MediaPreviewFragment
private fun jumpViewPagerToMedia(media: Media) {
val viewPagerAdapter = binding.mediaPager.adapter as MediaPreviewV2Adapter
val position = viewPagerAdapter.findItemPosition(media)
binding.mediaPager.setCurrentItem(position, true)
}
private fun getTitleText(mediaRecord: MediaDatabase.MediaRecord, showThread: Boolean): String {
val recipient: Recipient = Recipient.live(mediaRecord.recipientId).get()
val defaultFromString: String = if (mediaRecord.isOutgoing) {
@@ -482,6 +513,16 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
viewModel.onDestroyView()
}
private class OffsetSmoothScroller(context: Context, val offset: Int) : LinearSmoothScroller(context) {
override fun getHorizontalSnapPreference(): Int {
return SNAP_TO_START
}
override fun calculateDxToMakeVisible(view: View?, snapPreference: Int): Int {
return offset + super.calculateDxToMakeVisible(view, snapPreference)
}
}
companion object {
const val ARGS_KEY: String = "args"

View File

@@ -1,249 +0,0 @@
package org.thoughtcrime.securesms.mediapreview;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.MediaRailViewHolder> {
private static final int TYPE_MEDIA = 1;
private static final int TYPE_BUTTON = 2;
private final GlideRequests glideRequests;
private final List<Media> media;
private final RailItemListener listener;
private final StableIdGenerator<Media> stableIdGenerator;
private final ImageLoadingListener imageLoadingListener;
private RailItemAddListener addListener;
private int activePosition;
private boolean editable;
private boolean interactive;
public MediaRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemListener listener, boolean editable, ImageLoadingListener imageLoadingListener) {
this.glideRequests = glideRequests;
this.media = new ArrayList<>();
this.listener = listener;
this.editable = editable;
this.stableIdGenerator = new StableIdGenerator<>();
this.interactive = true;
this.imageLoadingListener = imageLoadingListener;
setHasStableIds(true);
}
@NonNull
@Override
public MediaRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int type) {
switch (type) {
case TYPE_MEDIA:
return new MediaViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediarail_media_item, viewGroup, false));
case TYPE_BUTTON:
return new ButtonViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediarail_button_item, viewGroup, false));
default:
throw new UnsupportedOperationException("Unsupported view type: " + type);
}
}
@Override
public void onBindViewHolder(@NonNull MediaRailViewHolder viewHolder, int i) {
switch (getItemViewType(i)) {
case TYPE_MEDIA:
((MediaViewHolder) viewHolder).bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, editable, interactive, imageLoadingListener);
break;
case TYPE_BUTTON:
((ButtonViewHolder) viewHolder).bind(addListener);
break;
default:
throw new UnsupportedOperationException("Unsupported view type: " + getItemViewType(i));
}
}
@Override
public int getItemViewType(int position) {
if (editable && position == getItemCount() - 1) {
return TYPE_BUTTON;
} else {
return TYPE_MEDIA;
}
}
@Override
public void onViewRecycled(@NonNull MediaRailViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return editable ? media.size() + 1 : media.size();
}
@Override
public long getItemId(int position) {
switch (getItemViewType(position)) {
case TYPE_MEDIA:
return stableIdGenerator.getId(media.get(position));
case TYPE_BUTTON:
return Long.MAX_VALUE;
default:
throw new UnsupportedOperationException("Unsupported view type: " + getItemViewType(position));
}
}
public void setMedia(@NonNull List<Media> media) {
setMedia(media, activePosition);
}
public void setMedia(@NonNull List<Media> records, int activePosition) {
this.activePosition = activePosition;
this.media.clear();
this.media.addAll(records);
notifyDataSetChanged();
}
public void setActivePosition(int activePosition) {
this.activePosition = activePosition;
notifyDataSetChanged();
}
public void setAddButtonListener(@Nullable RailItemAddListener addListener) {
this.addListener = addListener;
notifyDataSetChanged();
}
public void setEditable(boolean editable) {
this.editable = editable;
notifyDataSetChanged();
}
public void setInteractive(boolean interactive) {
this.interactive = interactive;
notifyDataSetChanged();
}
static abstract class MediaRailViewHolder extends RecyclerView.ViewHolder {
public MediaRailViewHolder(@NonNull View itemView) {
super(itemView);
}
abstract void recycle();
}
static class MediaViewHolder extends MediaRailViewHolder {
private final ThumbnailView image;
private final View outline;
private final View deleteButton;
private final View captionIndicator;
MediaViewHolder(@NonNull View itemView) {
super(itemView);
image = itemView.findViewById(R.id.rail_item_image);
outline = itemView.findViewById(R.id.rail_item_outline);
deleteButton = itemView.findViewById(R.id.rail_item_delete);
captionIndicator = itemView.findViewById(R.id.rail_item_caption);
}
void bind(@NonNull Media media, boolean isActive, @NonNull GlideRequests glideRequests,
@NonNull RailItemListener railItemListener, int distanceFromActive, boolean editable,
boolean interactive, @NonNull ImageLoadingListener listener)
{
listener.onRequest();
image.setImageResource(glideRequests, media.getUri(), 0, 0, false, listener);
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
outline.setVisibility(isActive && interactive ? View.VISIBLE : View.GONE);
captionIndicator.setVisibility(media.getCaption().isPresent() ? View.VISIBLE : View.GONE);
if (editable && isActive && interactive) {
deleteButton.setVisibility(View.VISIBLE);
deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive));
} else {
deleteButton.setVisibility(View.GONE);
}
}
void recycle() {
image.setOnClickListener(null);
deleteButton.setOnClickListener(null);
}
}
static class ButtonViewHolder extends MediaRailViewHolder {
public ButtonViewHolder(@NonNull View itemView) {
super(itemView);
}
void bind(@Nullable RailItemAddListener addListener) {
if (addListener != null) {
itemView.setOnClickListener(v -> addListener.onRailItemAddClicked());
}
}
@Override
void recycle() {
itemView.setOnClickListener(null);
}
}
public interface RailItemListener {
void onRailItemClicked(int distanceFromActive);
void onRailItemDeleteClicked(int distanceFromActive);
}
public interface RailItemAddListener {
void onRailItemAddClicked();
}
abstract static class ImageLoadingListener implements RequestListener<Drawable> {
final private AtomicInteger activeJobs = new AtomicInteger();
void onRequest() {
activeJobs.incrementAndGet();
}
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
int count = activeJobs.decrementAndGet();
if (count == 0) {
onAllRequestsFinished();
}
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
int count = activeJobs.decrementAndGet();
if (count == 0) {
onAllRequestsFinished();
}
return false;
}
abstract void onAllRequestsFinished();
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.mediapreview.mediarail
import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.core.view.doOnPreDraw
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
/**
* From: <a href="https://stackoverflow.com/a/53510142">https://stackoverflow.com/a/53510142</a>
*/
class CenterDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
private var firstViewWidth = -1
private var lastViewWidth = -1
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
val adapterPosition = (view.layoutParams as RecyclerView.LayoutParams).absoluteAdapterPosition
val layoutManager = parent.layoutManager as LinearLayoutManager
if (adapterPosition == 0) {
if (view.width != firstViewWidth) {
view.doOnPreDraw { parent.invalidateItemDecorations() }
}
firstViewWidth = view.width
outRect.left = parent.width / 2 - view.width / 2
if (layoutManager.itemCount > 1) {
outRect.right = spacing / 2
} else {
outRect.right = outRect.left
}
} else if (adapterPosition == layoutManager.itemCount - 1) {
if (view.width != lastViewWidth) {
view.doOnPreDraw { parent.invalidateItemDecorations() }
}
lastViewWidth = view.width
outRect.right = parent.width / 2 - view.width / 2
outRect.left = spacing / 2
} else {
outRect.left = spacing / 2
outRect.right = spacing / 2
}
}
}

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.mediapreview.mediarail
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ThumbnailView
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.atomic.AtomicInteger
/**
* This is the RecyclerView.Adapter for the row of thumbnails present in the media viewer screen.
*/
class MediaRailAdapter(private val glideRequests: GlideRequests, listener: RailItemListener, imageLoadingListener: ImageLoadingListener) : ListAdapter<Media, MediaRailAdapter.MediaRailViewHolder>(MediaDiffer()) {
val imageLoadingListener: ImageLoadingListener
var currentItemPosition: Int = -1
private val listener: RailItemListener
private val stableIdGenerator: StableIdGenerator<Media>
init {
this.listener = listener
stableIdGenerator = StableIdGenerator()
this.imageLoadingListener = imageLoadingListener
setHasStableIds(true)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, type: Int): MediaRailViewHolder {
return MediaRailViewHolder(LayoutInflater.from(viewGroup.context).inflate(R.layout.mediarail_media_item, viewGroup, false))
}
override fun onBindViewHolder(viewHolder: MediaRailViewHolder, i: Int) {
viewHolder.bind(getItem(i), i == currentItemPosition, glideRequests, listener, imageLoadingListener)
}
override fun onViewRecycled(holder: MediaRailViewHolder) {
holder.recycle()
}
override fun getItemId(position: Int): Long {
return stableIdGenerator.getId(getItem(position))
}
class MediaRailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val image: ThumbnailView
private val outline: View
private val captionIndicator: View
init {
image = itemView.findViewById(R.id.rail_item_image)
outline = itemView.findViewById(R.id.rail_item_outline)
captionIndicator = itemView.findViewById(R.id.rail_item_caption)
}
fun bind(
media: Media,
isCurrentlySelected: Boolean,
glideRequests: GlideRequests,
railItemListener: RailItemListener,
listener: ImageLoadingListener
) {
listener.onRequest()
image.setImageResource(glideRequests, media.uri, 0, 0, false, listener)
image.setOnClickListener { railItemListener.onRailItemClicked(media) }
captionIndicator.visibility = if (media.caption.isPresent) View.VISIBLE else View.GONE
setSelectedItem(isCurrentlySelected)
}
fun recycle() {
image.setOnClickListener(null)
}
fun setSelectedItem(isActive: Boolean) {
outline.visible = isActive
}
}
fun interface RailItemListener {
fun onRailItemClicked(media: Media)
}
abstract class ImageLoadingListener : RequestListener<Drawable?> {
private val activeJobs = AtomicInteger()
fun onRequest() {
activeJobs.incrementAndGet()
}
final override fun onLoadFailed(e: GlideException?, model: Any, target: Target<Drawable?>, isFirstResource: Boolean): Boolean {
val count = activeJobs.decrementAndGet()
if (count == 0) {
onAllRequestsFinished()
}
return false
}
final override fun onResourceReady(resource: Drawable?, model: Any, target: Target<Drawable?>, dataSource: DataSource, isFirstResource: Boolean): Boolean {
val count = activeJobs.decrementAndGet()
if (count == 0) {
onAllRequestsFinished()
}
return false
}
fun reset() {
activeJobs.set(0)
}
abstract fun onAllRequestsFinished()
}
class MediaDiffer : DiffUtil.ItemCallback<Media>() {
override fun areItemsTheSame(oldItem: Media, newItem: Media): Boolean {
return oldItem.uri == newItem.uri
}
override fun areContentsTheSame(oldItem: Media, newItem: Media): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -120,6 +120,10 @@ class Camera1Controller {
});
}
boolean isCameraFacingFront() {
return cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT;
}
int flip() {
Log.d(TAG, "flip()");
SurfaceTexture surfaceTexture = previewSurface;

View File

@@ -5,11 +5,13 @@ import android.annotation.SuppressLint;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.SurfaceTexture;
import android.graphics.drawable.Drawable;
import android.hardware.Camera;
import android.os.Bundle;
import android.view.Display;
import android.view.GestureDetector;
@@ -27,11 +29,9 @@ import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.cardview.widget.CardView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.view.ViewKt;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.MultiTransformation;
@@ -40,6 +40,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
@@ -48,10 +49,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations;
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.signal.core.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -64,26 +62,25 @@ import io.reactivex.rxjava3.disposables.Disposable;
* Camera capture implemented with the legacy camera API's. Should only be used if sdk < 21.
*/
public class Camera1Fragment extends LoggingFragment implements CameraFragment,
TextureView.SurfaceTextureListener,
Camera1Controller.EventListener
TextureView.SurfaceTextureListener,
Camera1Controller.EventListener
{
private static final String TAG = Log.tag(Camera1Fragment.class);
private TextureView cameraPreview;
private ViewGroup controlsContainer;
private ImageButton flipButton;
private View captureButton;
private Camera1Controller camera;
private Controller controller;
private OrderEnforcer<Stage> orderEnforcer;
private Camera1Controller.Properties properties;
private RotationListener rotationListener;
private Disposable rotationListenerDisposable;
private Disposable mostRecentItemDisposable = Disposable.disposed();
private boolean isThumbAvailable;
private boolean isMediaSelected;
private TextureView cameraPreview;
private ViewGroup controlsContainer;
private ImageButton flipButton;
private View captureButton;
private Camera1Controller camera;
private Controller controller;
private OrderEnforcer<Stage> orderEnforcer;
private Camera1Controller.Properties properties;
private RotationListener rotationListener;
private Disposable rotationListenerDisposable;
private Disposable mostRecentItemDisposable = Disposable.disposed();
private CameraScreenBrightnessController cameraScreenBrightnessController;
private boolean isMediaSelected;
public static Camera1Fragment newInstance() {
return new Camera1Fragment();
@@ -124,6 +121,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR);
cameraScreenBrightnessController = new CameraScreenBrightnessController(requireActivity().getWindow(), () -> camera.isCameraFacingFront());
getViewLifecycleOwner().getLifecycle().addObserver(cameraScreenBrightnessController);
rotationListener = new RotationListener(requireContext());
cameraPreview = view.findViewById(R.id.camera_preview);
@@ -271,21 +270,23 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
}
private void presentRecentItemThumbnail(@Nullable Media media) {
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
View thumbBackground = controlsContainer.findViewById(R.id.camera_gallery_button_background);
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media != null) {
thumbnail.setVisibility(View.VISIBLE);
thumbBackground.setBackgroundResource(R.drawable.circle_tintable);
thumbnail.clearColorFilter();
thumbnail.setScaleType(ImageView.ScaleType.FIT_CENTER);
Glide.with(this)
.load(new DecryptableUri(media.getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
thumbBackground.setBackgroundResource(R.drawable.media_selection_camera_switch_background);
thumbnail.setImageResource(R.drawable.ic_gallery_outline_24);
thumbnail.setColorFilter(Color.WHITE);
thumbnail.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
}
isThumbAvailable = media != null;
updateGalleryVisibility();
}
@Override
@@ -309,7 +310,7 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
private void updateGalleryVisibility() {
View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background);
if (isMediaSelected || !isThumbAvailable) {
if (isMediaSelected) {
cameraGalleryContainer.setVisibility(View.GONE);
} else {
cameraGalleryContainer.setVisibility(View.VISIBLE);
@@ -338,7 +339,7 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, () -> {
if (properties.getCameraCount() > 1) {
flipButton.setVisibility(properties.getCameraCount() > 1 ? View.VISIBLE : View.GONE);
flipButton.setOnClickListener(v -> {
flipButton.setOnClickListener(v -> {
int newCameraId = camera.flip();
TextSecurePreferences.setDirectCaptureCameraId(getContext(), newCameraId);
@@ -346,6 +347,7 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
animation.setDuration(200);
animation.setInterpolator(new DecelerateInterpolator());
flipButton.startAnimation(animation);
cameraScreenBrightnessController.onCameraDirectionChanged(newCameraId == Camera.CameraInfo.CAMERA_FACING_FRONT);
});
} else {
flipButton.setVisibility(View.GONE);

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.mediasend
import android.view.Window
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* Modifies screen brightness to increase to a max of 66% if lower than that for optimal picture
* taking conditions. This brightness is only applied when the front-facing camera is selected.
*/
class CameraScreenBrightnessController(private val window: Window, private val cameraDirectionProvider: CameraDirectionProvider) : DefaultLifecycleObserver {
companion object {
private const val FRONT_CAMERA_BRIGHTNESS = 0.66f
}
private val originalBrightness: Float by lazy { window.attributes.screenBrightness }
override fun onResume(owner: LifecycleOwner) {
onCameraDirectionChanged(cameraDirectionProvider.isFrontFacingCameraSelected())
}
override fun onPause(owner: LifecycleOwner) {
disableBrightness()
}
/**
* Because setting camera direction is an asynchronous action, we cannot rely on
* the `CameraDirectionProvider` at this point.
*/
fun onCameraDirectionChanged(isFrontFacing: Boolean) {
if (isFrontFacing) {
enableBrightness()
} else {
disableBrightness()
}
}
private fun enableBrightness() {
if (originalBrightness < FRONT_CAMERA_BRIGHTNESS) {
window.attributes = window.attributes.apply {
screenBrightness = FRONT_CAMERA_BRIGHTNESS
}
}
}
private fun disableBrightness() {
if (window.attributes.screenBrightness == FRONT_CAMERA_BRIGHTNESS) {
window.attributes = window.attributes.apply {
screenBrightness = originalBrightness
}
}
}
interface CameraDirectionProvider {
fun isFrontFacingCameraSelected(): Boolean
}
}

View File

@@ -6,6 +6,7 @@ import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.util.Rational;
@@ -81,17 +82,16 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
private static final PreviewView.ScaleType PREVIEW_SCALE_TYPE = PreviewView.ScaleType.FILL_CENTER;
private PreviewView previewView;
private ViewGroup controlsContainer;
private Controller controller;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
private LifecycleCameraController cameraController;
private Disposable mostRecentItemDisposable = Disposable.disposed();
private CameraXModePolicy cameraXModePolicy;
private boolean isThumbAvailable;
private boolean isMediaSelected;
private PreviewView previewView;
private ViewGroup controlsContainer;
private Controller controller;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
private LifecycleCameraController cameraController;
private Disposable mostRecentItemDisposable = Disposable.disposed();
private CameraXModePolicy cameraXModePolicy;
private CameraScreenBrightnessController cameraScreenBrightnessController;
private boolean isMediaSelected;
public static CameraXFragment newInstanceForAvatarCapture() {
CameraXFragment fragment = new CameraXFragment();
@@ -134,6 +134,11 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
@SuppressLint("MissingPermission")
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
cameraScreenBrightnessController = new CameraScreenBrightnessController(
requireActivity().getWindow(),
() -> cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA
);
ViewGroup cameraParent = view.findViewById(R.id.camerax_camera_parent);
this.previewView = view.findViewById(R.id.camerax_camera);
@@ -243,21 +248,23 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
}
private void presentRecentItemThumbnail(@Nullable Media media) {
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
View thumbBackground = controlsContainer.findViewById(R.id.camera_gallery_button_background);
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media != null) {
thumbnail.setVisibility(View.VISIBLE);
thumbBackground.setBackgroundResource(R.drawable.circle_tintable);
thumbnail.clearColorFilter();
thumbnail.setScaleType(ImageView.ScaleType.FIT_CENTER);
Glide.with(this)
.load(new DecryptableUri(media.getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
thumbBackground.setBackgroundResource(R.drawable.media_selection_camera_switch_background);
thumbnail.setImageResource(R.drawable.ic_gallery_outline_24);
thumbnail.setColorFilter(Color.WHITE);
thumbnail.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
}
isThumbAvailable = media != null;
updateGalleryVisibility();
}
@Override
@@ -278,7 +285,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private void updateGalleryVisibility() {
View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background);
if (isMediaSelected || !isThumbAvailable) {
if (isMediaSelected) {
cameraGalleryContainer.setVisibility(View.GONE);
} else {
cameraGalleryContainer.setVisibility(View.VISIBLE);
@@ -514,12 +521,14 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
return;
}
getViewLifecycleOwner().getLifecycle().addObserver(cameraScreenBrightnessController);
if (cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) && cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) {
flipButton.setVisibility(View.VISIBLE);
flipButton.setOnClickListener(v -> {
cameraController.setCameraSelector(cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA
? CameraSelector.DEFAULT_BACK_CAMERA
: CameraSelector.DEFAULT_FRONT_CAMERA);
CameraSelector cameraSelector = cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA
? CameraSelector.DEFAULT_BACK_CAMERA
: CameraSelector.DEFAULT_FRONT_CAMERA;
cameraController.setCameraSelector(cameraSelector);
TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(cameraController.getCameraSelector()));
Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f);
@@ -528,6 +537,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
flipButton.startAnimation(animation);
flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO);
flashButton.setFlash(cameraController.getImageCaptureFlashMode());
cameraScreenBrightnessController.onCameraDirectionChanged(cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA);
});
GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() {

View File

@@ -4,8 +4,6 @@ import android.content.Context
import androidx.annotation.WorkerThread
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.SmsExportPhase
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Util
import kotlin.time.Duration.Companion.days
class SmsExportReminderSchedule(private val context: Context) : MegaphoneSchedule {
@@ -32,17 +30,8 @@ class SmsExportReminderSchedule(private val context: Context) : MegaphoneSchedul
}
}
@Suppress("UsePropertyAccessSyntax")
@WorkerThread
fun shouldShowMegaphone(): Boolean {
return if (SignalStore.misc().storiesFeatureAvailableTimestamp == 0L) {
SignalStore.misc().storiesFeatureAvailableTimestamp = System.currentTimeMillis()
false
} else if (System.currentTimeMillis() > (SignalStore.misc().storiesFeatureAvailableTimestamp + FeatureFlags.smsExportMegaphoneDelayDays().days.inWholeMilliseconds)) {
SignalStore.misc().startSmsPhase1()
FeatureFlags.smsExporter() && Util.isDefaultSmsProvider(context)
} else {
false
}
private fun shouldShowMegaphone(): Boolean {
return false
}
}

View File

@@ -13,6 +13,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.jobmanager.Data;
@@ -76,8 +77,8 @@ public class UserNotificationMigrationJob extends MigrationJob {
ThreadDatabase threadDatabase = SignalDatabase.threads();
int threadCount = threadDatabase.getUnarchivedConversationListCount() +
threadDatabase.getArchivedConversationListCount();
int threadCount = threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF) +
threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF);
if (threadCount >= 3) {
Log.w(TAG, "Already have 3 or more threads. Skipping.");

View File

@@ -101,6 +101,12 @@ final class ConfirmPaymentViewModel extends ViewModel {
confirmPaymentRepository.confirmPayment(store.getState(), this::handleConfirmPaymentResult);
}
@Override
protected void onCleared() {
super.onCleared();
store.clear();
}
void refreshFee() {
feeRetry.setValue(true);
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.payments.create;
import android.app.AlertDialog;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.TextUtils;
@@ -158,6 +157,20 @@ public class CreatePaymentFragment extends LoggingFragment {
});
}
@Override
public void onDestroyView() {
super.onDestroyView();
constraintLayout = null;
addNote = null;
balance = null;
amount = null;
exchange = null;
request = null;
toggle = null;
note = null;
pay = null;
}
private void goBack(View v) {
if (!Navigation.findNavController(v).popBackStack()) {
requireActivity().finish();

View File

@@ -55,8 +55,6 @@ public class PaymentsHomeFragment extends LoggingFragment {
private PaymentsHomeViewModel viewModel;
private final OnBackPressed onBackPressed = new OnBackPressed();
public PaymentsHomeFragment() {
super(R.layout.payments_home_fragment);
}
@@ -270,7 +268,7 @@ public class PaymentsHomeFragment extends LoggingFragment {
}
});
requireActivity().getOnBackPressedDispatcher().addCallback(onBackPressed);
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressed());
}
@Override
@@ -279,12 +277,6 @@ public class PaymentsHomeFragment extends LoggingFragment {
viewModel.checkPaymentActivationState();
}
@Override
public void onDestroyView() {
super.onDestroyView();
onBackPressed.setEnabled(false);
}
private void showUpdateIsRequiredDialog() {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.PaymentsHomeFragment__update_required))

View File

@@ -88,6 +88,12 @@ public class PaymentsHomeViewModel extends ViewModel {
refreshExchangeRates(true);
}
@Override
protected void onCleared() {
super.onCleared();
store.clear();
}
private static PaymentsHomeState.PaymentsState getPaymentsState() {
PaymentsValues paymentsValues = SignalStore.paymentsValues();

View File

@@ -92,7 +92,7 @@ public final class PaymentsTransferFragment extends LoggingFragment {
}
private void scanQrCode() {
Permissions.with(requireActivity())
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs), R.drawable.ic_camera_24)
@@ -110,4 +110,10 @@ public final class PaymentsTransferFragment extends LoggingFragment {
.setNegativeButton(android.R.string.cancel, null)
.show();
}
@Override
@SuppressWarnings("deprecation")
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
}

View File

@@ -7,6 +7,7 @@ import android.database.Cursor;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
@@ -48,15 +49,19 @@ public final class LiveRecipientCache {
private final AtomicReference<RecipientId> localRecipientId;
private final AtomicBoolean warmedUp;
@SuppressLint("UseSparseArrays")
public LiveRecipientCache(@NonNull Context context) {
this(context, ThreadUtil.trace(new FilteredExecutor(SignalExecutors.newCachedBoundedExecutor("signal-recipients", 1, 4, 15), () -> !SignalDatabase.inTransaction())));
}
@VisibleForTesting
public LiveRecipientCache(@NonNull Context context, @NonNull Executor executor) {
this.context = context.getApplicationContext();
this.recipientDatabase = SignalDatabase.recipients();
this.recipients = new LRUCache<>(CACHE_MAX);
this.warmedUp = new AtomicBoolean(false);
this.localRecipientId = new AtomicReference<>(null);
this.unknown = new LiveRecipient(context, Recipient.UNKNOWN);
this.resolveExecutor = ThreadUtil.trace(new FilteredExecutor(SignalExecutors.newCachedBoundedExecutor("signal-recipients", 1, 4, 15), () -> !SignalDatabase.inTransaction()));
this.resolveExecutor = executor;
}
@AnyThread

View File

@@ -5,6 +5,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallException;
import org.signal.ringrtc.CallManager;
import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
@@ -88,7 +89,7 @@ public class IdleActionProcessor extends WebRtcActionProcessor {
@NonNull RemotePeer remotePeerGroup,
@NonNull GroupId.V2 groupId,
long ringId,
@NonNull UUID uuid,
@NonNull UUID sender,
@NonNull CallManager.RingUpdate ringUpdate)
{
Log.i(TAG, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate);
@@ -118,14 +119,14 @@ public class IdleActionProcessor extends WebRtcActionProcessor {
return currentState;
}
webRtcInteractor.peekGroupCallForRingingCheck(new GroupCallRingCheckInfo(remotePeerGroup.getId(), groupId, ringId, uuid, ringUpdate));
webRtcInteractor.peekGroupCallForRingingCheck(new GroupCallRingCheckInfo(remotePeerGroup.getId(), groupId, ringId, sender, ringUpdate));
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, long deviceCount) {
Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck(): recipient: " + info.getRecipientId() + " ring: " + info.getRingId() + " deviceCount: " + deviceCount);
protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, @NonNull PeekInfo peekInfo) {
Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck(): recipient: " + info.getRecipientId() + " ring: " + info.getRingId());
if (SignalDatabase.groupCallRings().isCancelled(info.getRingId())) {
try {
@@ -137,10 +138,14 @@ public class IdleActionProcessor extends WebRtcActionProcessor {
return currentState;
}
if (deviceCount == 0) {
if (peekInfo.getDeviceCount() == 0) {
Log.i(TAG, "No one in the group call, mark as expired and do not ring");
SignalDatabase.groupCallRings().insertOrUpdateGroupRing(info.getRingId(), System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST);
return currentState;
} else if (peekInfo.getJoinedMembers().contains(Recipient.self().requireServiceId().uuid())) {
Log.i(TAG, "We are already in the call, mark accepted on another device and do not ring");
SignalDatabase.groupCallRings().insertOrUpdateGroupRing(info.getRingId(), System.currentTimeMillis(), CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE);
return currentState;
}
currentState = currentState.builder()

View File

@@ -49,7 +49,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
@NonNull RemotePeer remotePeerGroup,
@NonNull GroupId.V2 groupId,
long ringId,
@NonNull UUID uuid,
@NonNull UUID sender,
@NonNull CallManager.RingUpdate ringUpdate)
{
Log.i(TAG, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate);
@@ -138,7 +138,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
.changeCallSetupState(RemotePeer.GROUP_CALL_ID)
.isRemoteVideoOffer(true)
.ringId(ringId)
.ringerRecipient(Recipient.externalPush(ServiceId.from(uuid)))
.ringerRecipient(Recipient.externalPush(ServiceId.from(sender)))
.commit()
.changeCallInfoState()
.activePeer(new RemotePeer(currentState.getCallInfoState().getCallRecipient().getId(), RemotePeer.GROUP_CALL_ID))

View File

@@ -25,6 +25,7 @@ import org.signal.ringrtc.CallManager;
import org.signal.ringrtc.GroupCall;
import org.signal.ringrtc.HttpHeader;
import org.signal.ringrtc.NetworkRoute;
import org.signal.ringrtc.PeekInfo;
import org.signal.ringrtc.Remote;
import org.signal.storageservice.protos.groups.GroupExternalCredential;
import org.thoughtcrime.securesms.WebRtcCallActivity;
@@ -300,8 +301,8 @@ private void processStateless(@NonNull Function1<WebRtcEphemeralState, WebRtcEph
process((s, p) -> p.handleSetRingGroup(s, ringGroup));
}
private void receivedGroupCallPeekForRingingCheck(@NonNull GroupCallRingCheckInfo groupCallRingCheckInfo, long deviceCount) {
process((s, p) -> p.handleReceivedGroupCallPeekForRingingCheck(s, groupCallRingCheckInfo, deviceCount));
private void receivedGroupCallPeekForRingingCheck(@NonNull GroupCallRingCheckInfo groupCallRingCheckInfo, @NonNull PeekInfo peekInfo) {
process((s, p) -> p.handleReceivedGroupCallPeekForRingingCheck(s, groupCallRingCheckInfo, peekInfo));
}
public void onAudioDeviceChanged(@NonNull SignalAudioManager.AudioDevice activeDevice, @NonNull Set<SignalAudioManager.AudioDevice> availableDevices) {
@@ -379,9 +380,10 @@ private void processStateless(@NonNull Function1<WebRtcEphemeralState, WebRtcEph
.map(entry -> new GroupCall.GroupMemberInfo(entry.getKey(), entry.getValue().serialize()))
.collect(Collectors.toList());
callManager.peekGroupCall(SignalStore.internalValues().groupCallingServer(), credential.getTokenBytes().toByteArray(), members, peekInfo -> {
receivedGroupCallPeekForRingingCheck(info, peekInfo.getDeviceCount());
});
callManager.peekGroupCall(SignalStore.internalValues().groupCallingServer(),
credential.getTokenBytes().toByteArray(),
members,
peekInfo -> receivedGroupCallPeekForRingingCheck(info, peekInfo));
} catch (IOException | VerificationFailedException | CallException e) {
Log.e(TAG, "error peeking for ringing check", e);
}
@@ -741,13 +743,18 @@ private void processStateless(@NonNull Function1<WebRtcEphemeralState, WebRtcEph
}
@Override
public void onGroupCallRingUpdate(@NonNull byte[] groupIdBytes, long ringId, @NonNull UUID uuid, @NonNull CallManager.RingUpdate ringUpdate) {
public void onGroupCallRingUpdate(@NonNull byte[] groupIdBytes, long ringId, @NonNull UUID sender, @NonNull CallManager.RingUpdate ringUpdate) {
try {
GroupId.V2 groupId = GroupId.v2(new GroupIdentifier(groupIdBytes));
GroupDatabase.GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null);
GroupId.V2 groupId = GroupId.v2(new GroupIdentifier(groupIdBytes));
GroupDatabase.GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null);
Recipient senderRecipient = Recipient.externalPush(ServiceId.from(sender));
if (group != null && group.isActive() && !Recipient.resolved(group.getRecipientId()).isBlocked()) {
process((s, p) -> p.handleGroupCallRingUpdate(s, new RemotePeer(group.getRecipientId()), groupId, ringId, uuid, ringUpdate));
if (group != null &&
group.isActive() &&
!Recipient.resolved(group.getRecipientId()).isBlocked() &&
(!group.isAnnouncementGroup() || group.isAdmin(senderRecipient)))
{
process((s, p) -> p.handleGroupCallRingUpdate(s, new RemotePeer(group.getRecipientId()), groupId, ringId, sender, ringUpdate));
} else {
Log.w(TAG, "Unable to ring unknown/inactive/blocked group.");
}

View File

@@ -17,6 +17,7 @@ import org.signal.ringrtc.CallManager;
import org.signal.ringrtc.CallManager.RingUpdate;
import org.signal.ringrtc.GroupCall;
import org.signal.ringrtc.NetworkRoute;
import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
@@ -786,7 +787,7 @@ public abstract class WebRtcActionProcessor {
@NonNull RemotePeer remotePeerGroup,
@NonNull GroupId.V2 groupId,
long ringId,
@NonNull UUID uuid,
@NonNull UUID sender,
@NonNull RingUpdate ringUpdate)
{
Log.i(tag, "handleGroupCallRingUpdate(): recipient: " + remotePeerGroup.getId() + " ring: " + ringId + " update: " + ringUpdate);
@@ -810,7 +811,7 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, long deviceCount) {
protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, @NonNull PeekInfo peekInfo) {
Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck not processed");
return currentState;

View File

@@ -124,6 +124,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean hasViewedOnboardingStory = remote.hasViewedOnboardingStory();
boolean storiesDisabled = remote.isStoriesDisabled();
boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ;
boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet();
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory);
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory);
@@ -161,6 +162,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
.setHasViewedOnboardingStory(hasViewedOnboardingStory)
.setStoriesDisabled(storiesDisabled)
.setHasReadOnboardingStory(hasReadOnboardingStory)
.setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation)
.build();
}
}

View File

@@ -152,6 +152,7 @@ public final class StorageSyncHelper {
.setStoriesDisabled(SignalStore.storyValues().isFeatureDisabled())
.setStoryViewReceiptsState(storyViewReceiptsState)
.setHasReadOnboardingStory(hasReadOnboardingStory)
.setHasSeenGroupStoryEducationSheet(SignalStore.storyValues().getUserHasSeenGroupStoryEducationSheet())
.build();
return SignalStorageRecord.forAccount(account);
@@ -181,6 +182,7 @@ public final class StorageSyncHelper {
SignalStore.storyValues().setUserHasViewedOnboardingStory(update.getNew().hasViewedOnboardingStory());
SignalStore.storyValues().setFeatureDisabled(update.getNew().isStoriesDisabled());
SignalStore.storyValues().setUserHasReadOnboardingStory(update.getNew().hasReadOnboardingStory());
SignalStore.storyValues().setUserHasSeenGroupStoryEducationSheet(update.getNew().hasSeenGroupStoryEducationSheet());
if (update.getNew().getStoryViewReceiptsState() == OptionalBool.UNSET) {
SignalStore.storyValues().setViewedReceiptsEnabled(update.getNew().isReadReceiptsEnabled());

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.stories
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.button.MaterialButton
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Displays an education sheet to the user which explains what Group Stories are.
*/
class GroupStoryEducationSheet : FixedRoundedCornerBottomSheetDialogFragment() {
companion object {
const val KEY = "GROUP_STORY_EDU"
}
override val peekHeightPercentage: Float = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.group_story_education_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = true
SignalExecutors.BOUNDED_IO.execute { Stories.onStorySettingsChanged(Recipient.self().id) }
view.findViewById<MaterialButton>(R.id.next).setOnClickListener {
requireListener<Callback>().onGroupStoryEducationSheetNext()
dismissAllowingStateLoss()
}
}
interface Callback {
fun onGroupStoryEducationSheetNext()
}
}

View File

@@ -16,8 +16,10 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
@@ -34,7 +36,8 @@ class StoriesPrivacySettingsFragment :
DSLSettingsFragment(
titleId = R.string.preferences__stories
),
ChooseStoryTypeBottomSheet.Callback {
ChooseStoryTypeBottomSheet.Callback,
GroupStoryEducationSheet.Callback {
private val viewModel: StoriesPrivacySettingsViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
@@ -181,10 +184,18 @@ class StoriesPrivacySettingsFragment :
}
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
if (SignalStore.storyValues().userHasSeenGroupStoryEducationSheet) {
onGroupStoryEducationSheetNext()
} else {
GroupStoryEducationSheet().show(childFragmentManager, GroupStoryEducationSheet.KEY)
}
}
override fun onNewStoryClicked() {
CreateStoryFlowDialogFragment().show(parentFragmentManager, CreateStoryWithViewersFragment.REQUEST_KEY)
}
override fun onGroupStoryEducationSheetNext() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
}
}

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import com.bumptech.glide.Priority
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
@@ -19,6 +20,11 @@ class StoryCache(
private val glideRequests: GlideRequests,
private val storySize: StoryDisplay.Size
) {
companion object {
private val TAG = Log.tag(StoryCache::class.java)
}
private val cache = mutableMapOf<Uri, StoryCacheValue>()
/**
@@ -26,18 +32,25 @@ class StoryCache(
* downloaded, not images, or already in progress.
*/
fun prefetch(attachments: List<Attachment>) {
Log.d(TAG, "Loading ${attachments.size} attachments at $storySize")
val prefetchableAttachments: List<Attachment> = attachments
.asSequence()
.filter { it.uri != null && it.uri !in cache }
.filter { MediaUtil.isImage(it) }
.filter { MediaUtil.isImage(it) || it.blurHash != null }
.filter { it.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE }
.toList()
val newMappings: Map<Uri, StoryCacheValue> = prefetchableAttachments.associateWith { attachment ->
val imageTarget = glideRequests
.load(DecryptableStreamUriLoader.DecryptableUri(attachment.uri!!))
.priority(Priority.HIGH)
.into(StoryCacheTarget(attachment.uri!!, storySize))
val imageTarget = if (MediaUtil.isImage(attachment)) {
glideRequests
.load(DecryptableStreamUriLoader.DecryptableUri(attachment.uri!!))
.priority(Priority.HIGH)
.centerInside()
.into(StoryCacheTarget(attachment.uri!!, storySize))
} else {
null
}
val blurTarget = if (attachment.blurHash != null) {
glideRequests
@@ -79,7 +92,7 @@ class StoryCache(
/**
* Represents the load targets for an image and blur.
*/
data class StoryCacheValue(val imageTarget: StoryCacheTarget, val blurTarget: StoryCacheTarget?)
data class StoryCacheValue(val imageTarget: StoryCacheTarget?, val blurTarget: StoryCacheTarget?)
/**
* A custom glide target for loading a drawable. Placeholder immediately clears, and we don't want to do that, so we use this instead.

View File

@@ -6,6 +6,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.Objects
/**
* Each story is made up of a collection of posts
@@ -44,5 +45,13 @@ data class StoryPost(
abstract fun isVideo(): Boolean
abstract fun isText(): Boolean
override fun equals(other: Any?): Boolean {
return other != null && other::class.java == this::class.java && other.hashCode() == hashCode()
}
override fun hashCode(): Int {
return Objects.hash(uri, isVideo(), isText(), transferState)
}
}
}

View File

@@ -107,6 +107,8 @@ class StoryViewerPageFragment :
private lateinit var storyPageContainer: ConstraintLayout
private lateinit var sendingBarTextView: TextView
private lateinit var sendingBar: View
private lateinit var storyNormalBottomGradient: View
private lateinit var storyCaptionBottomGradient: View
private lateinit var callback: Callback
@@ -172,9 +174,11 @@ class StoryViewerPageFragment :
val largeCaptionOverlay: View = view.findViewById(R.id.story_large_caption_overlay)
val reactionAnimationView: OnReactionSentView = view.findViewById(R.id.on_reaction_sent_view)
val storyGradientTop: View = view.findViewById(R.id.story_gradient_top)
val storyGradientBottom: View = view.findViewById(R.id.story_gradient_bottom)
val storyGradientBottom: View = view.findViewById(R.id.story_bottom_gradient_container)
val storyVolumeOverlayView: StoryVolumeOverlayView = view.findViewById(R.id.story_volume_overlay)
storyNormalBottomGradient = view.findViewById(R.id.story_gradient_bottom)
storyCaptionBottomGradient = view.findViewById(R.id.story_caption_gradient)
storyPageContainer = view.findViewById(R.id.story_page_container)
storyContentContainer = view.findViewById(R.id.story_content_container)
storyCaptionContainer = view.findViewById(R.id.story_caption_container)
@@ -198,6 +202,7 @@ class StoryViewerPageFragment :
progressBar,
storyGradientTop,
storyGradientBottom,
storyCaptionContainer
)
senderAvatar.setFallbackPhotoProvider(FallbackPhotoProvider())
@@ -393,6 +398,7 @@ class StoryViewerPageFragment :
storyPost.sender.isReleaseNotes -> ONBOARDING_DURATION
storyPost.content.isVideo() -> -1L
storyPost.content is StoryPost.Content.TextContent -> calculateDurationForText(storyPost.content)
storyPost.content is StoryPost.Content.AttachmentContent -> calculateDurationForAttachment(storyPost.content)
else -> DEFAULT_DURATION
}
}
@@ -433,15 +439,12 @@ class StoryViewerPageFragment :
when {
state.hideChromeImmediate -> {
hideChromeImmediate()
storyCaptionContainer.visible = false
}
state.hideChrome -> {
hideChrome()
storyCaptionContainer.visible = true
}
else -> {
showChrome()
storyCaptionContainer.visible = true
}
}
}
@@ -499,7 +502,20 @@ class StoryViewerPageFragment :
}
private fun calculateDurationForText(textContent: StoryPost.Content.TextContent): Long {
val divisionsOf15 = textContent.length / CHARACTERS_PER_SECOND
return calculateDurationForContentLength(textContent.length)
}
private fun calculateDurationForAttachment(attachmentContent: StoryPost.Content.AttachmentContent): Long {
val caption: String? = attachmentContent.attachment.caption
return if (caption.isNullOrEmpty()) {
DEFAULT_DURATION
} else {
max(DEFAULT_DURATION, calculateDurationForContentLength(caption.length))
}
}
private fun calculateDurationForContentLength(contentLength: Int): Long {
val divisionsOf15 = contentLength / CHARACTERS_PER_SECOND
return TimeUnit.SECONDS.toMillis(divisionsOf15) + MIN_TEXT_STORY_PLAYBACK
}
@@ -779,6 +795,9 @@ class StoryViewerPageFragment :
""
}
storyNormalBottomGradient.visible = !displayBody.isNotEmpty()
storyCaptionBottomGradient.visible = displayBody.isNotEmpty()
caption.text = displayBody
largeCaption.text = displayBody
caption.visible = displayBody.isNotEmpty()

View File

@@ -73,7 +73,7 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
}
}
private fun getStoryPostFromRecord(recipientId: RecipientId, record: MessageRecord): Observable<StoryPost> {
private fun getStoryPostFromRecord(recipientId: RecipientId, originalRecord: MessageRecord): Observable<StoryPost> {
return Observable.create { emitter ->
fun refresh(record: MessageRecord) {
val recipient = Recipient.resolved(recipientId)
@@ -94,12 +94,14 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
emitter.onNext(story)
}
val recordId = originalRecord.id
val threadId = originalRecord.threadId
val recipient = Recipient.resolved(recipientId)
val messageUpdateObserver = DatabaseObserver.MessageObserver {
if (it.mms && it.id == record.id) {
if (it.mms && it.id == recordId) {
try {
val messageRecord = SignalDatabase.mms.getMessageRecord(record.id)
val messageRecord = SignalDatabase.mms.getMessageRecord(recordId)
if (messageRecord.isRemoteDelete) {
emitter.onComplete()
} else {
@@ -113,21 +115,21 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
val conversationObserver = DatabaseObserver.Observer {
try {
refresh(SignalDatabase.mms.getMessageRecord(record.id))
refresh(SignalDatabase.mms.getMessageRecord(recordId))
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Message deleted during content refresh.", e)
}
}
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(record.threadId, conversationObserver)
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, conversationObserver)
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver)
val messageInsertObserver = DatabaseObserver.MessageObserver {
refresh(SignalDatabase.mms.getMessageRecord(record.id))
refresh(SignalDatabase.mms.getMessageRecord(recordId))
}
if (recipient.isGroup) {
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(record.threadId, messageInsertObserver)
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageInsertObserver)
}
emitter.setCancellable {
@@ -139,7 +141,7 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
}
}
refresh(record)
refresh(originalRecord)
}
}
@@ -150,7 +152,9 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean): Observable<List<StoryPost>> {
return getStoryRecords(recipientId, isOutgoingOnly)
.switchMap { records ->
val posts = records.map { getStoryPostFromRecord(recipientId, it) }
val posts: List<Observable<StoryPost>> = records.map {
getStoryPostFromRecord(recipientId, it).distinctUntilChanged()
}
if (posts.isEmpty()) {
Observable.just(emptyList())
} else {

View File

@@ -12,6 +12,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
@@ -87,11 +88,13 @@ class StoryViewerPageViewModel(
)
}
storyCache.prefetch(
posts.map { it.content }
.filterIsInstance<StoryPost.Content.AttachmentContent>()
.map { it.attachment }
)
val attachments: List<Attachment> = posts.map { it.content }
.filterIsInstance<StoryPost.Content.AttachmentContent>()
.map { it.attachment }
if (attachments.isNotEmpty()) {
storyCache.prefetch(attachments)
}
}
disposables += storyLongPressSubject.debounce(150, TimeUnit.MILLISECONDS).subscribe { isLongPress ->

View File

@@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.stories.viewer.post
import android.graphics.drawable.Drawable
import android.net.Uri
import android.widget.ImageView
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.stories.viewer.page.StoryCache
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay
/**
* Responsible for managing the lifecycle around loading a BlurHash
*/
class StoryBlurLoader(
private val lifecycle: Lifecycle,
private val blurHash: BlurHash?,
private val cacheKey: Uri,
private val storyCache: StoryCache,
private val storySize: StoryDisplay.Size,
private val blurImage: ImageView,
private val callback: Callback = NO_OP
) {
companion object {
private val TAG = Log.tag(StoryBlurLoader::class.java)
private val NO_OP = object : Callback {
override fun onBlurLoaded() = Unit
override fun onBlurFailed() = Unit
}
}
private val blurListener = object : StoryCache.Listener {
override fun onResourceReady(resource: Drawable) {
blurImage.setImageDrawable(resource)
callback.onBlurLoaded()
}
override fun onLoadFailed() {
callback.onBlurFailed()
}
}
fun load() {
val cacheValue = storyCache.getFromCache(cacheKey)
if (cacheValue != null) {
loadViaCache(cacheValue)
} else {
loadViaGlide(blurHash, storySize)
}
}
fun clear() {
GlideApp.with(blurImage).clear(blurImage)
blurImage.setImageDrawable(null)
}
private fun loadViaCache(cacheValue: StoryCache.StoryCacheValue) {
Log.d(TAG, "Blur in cache. Loading via cache...")
val blurTarget = cacheValue.blurTarget
if (blurTarget != null) {
blurTarget.addListener(blurListener)
lifecycle.addObserver(OnDestroy { blurTarget.removeListener(blurListener) })
} else {
callback.onBlurFailed()
}
}
private fun loadViaGlide(blurHash: BlurHash?, storySize: StoryDisplay.Size) {
if (blurHash != null) {
GlideApp.with(blurImage)
.load(blurHash)
.override(storySize.width, storySize.height)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
callback.onBlurFailed()
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
callback.onBlurLoaded()
return false
}
})
.into(blurImage)
} else {
callback.onBlurFailed()
}
}
interface Callback {
fun onBlurLoaded()
fun onBlurFailed()
}
private inner class OnDestroy(private val onDestroy: () -> Unit) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
onDestroy()
}
}
}

View File

@@ -9,7 +9,6 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.stories.viewer.page.StoryCache
@@ -24,9 +23,19 @@ class StoryImageLoader(
private val storyCache: StoryCache,
private val storySize: StoryDisplay.Size,
private val postImage: ImageView,
private val blurImage: ImageView,
blurImage: ImageView,
private val callback: StoryPostFragment.Callback
) {
) : StoryBlurLoader.Callback {
private val blurLoader = StoryBlurLoader(
fragment.viewLifecycleOwner.lifecycle,
imagePost.blurHash,
imagePost.imageUri,
storyCache,
storySize,
blurImage,
this
)
companion object {
private val TAG = Log.tag(StoryImageLoader::class.java)
@@ -37,6 +46,7 @@ class StoryImageLoader(
private val imageListener = object : StoryCache.Listener {
override fun onResourceReady(resource: Drawable) {
Log.d(TAG, "Loaded cached resource of size w${resource.intrinsicWidth} x h${resource.intrinsicHeight}")
postImage.setImageDrawable(resource)
imageState = LoadState.READY
notifyListeners()
@@ -48,80 +58,39 @@ class StoryImageLoader(
}
}
private val blurListener = object : StoryCache.Listener {
override fun onResourceReady(resource: Drawable) {
blurImage.setImageDrawable(resource)
blurState = LoadState.READY
notifyListeners()
}
override fun onLoadFailed() {
blurState = LoadState.FAILED
notifyListeners()
}
}
fun load() {
val cacheValue = storyCache.getFromCache(imagePost.imageUri)
if (cacheValue != null) {
loadViaCache(cacheValue)
} else {
loadViaGlide(imagePost.blurHash, storySize)
loadViaGlide(storySize)
}
blurLoader.load()
}
fun clear() {
GlideApp.with(postImage).clear(postImage)
GlideApp.with(blurImage).clear(blurImage)
postImage.setImageDrawable(null)
blurImage.setImageDrawable(null)
blurLoader.clear()
}
private fun loadViaCache(cacheValue: StoryCache.StoryCacheValue) {
Log.d(TAG, "Attachment in cache. Loading via cache...")
val blurTarget = cacheValue.blurTarget
if (blurTarget != null) {
blurTarget.addListener(blurListener)
fragment.viewLifecycleOwner.lifecycle.addObserver(OnDestroy { blurTarget.removeListener(blurListener) })
} else {
blurState = LoadState.FAILED
notifyListeners()
}
Log.d(TAG, "Image in cache. Loading via cache...")
val imageTarget = cacheValue.imageTarget
val imageTarget = cacheValue.imageTarget!!
imageTarget.addListener(imageListener)
fragment.viewLifecycleOwner.lifecycle.addObserver(OnDestroy { imageTarget.removeListener(blurListener) })
fragment.viewLifecycleOwner.lifecycle.addObserver(OnDestroy { imageTarget.removeListener(imageListener) })
}
private fun loadViaGlide(blurHash: BlurHash?, storySize: StoryDisplay.Size) {
Log.d(TAG, "Attachment not in cache. Loading via glide...")
if (blurHash != null) {
GlideApp.with(blurImage)
.load(blurHash)
.override(storySize.width, storySize.height)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
blurState = LoadState.FAILED
notifyListeners()
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
blurState = LoadState.READY
notifyListeners()
return false
}
})
.into(blurImage)
} else {
blurState = LoadState.FAILED
notifyListeners()
}
private fun loadViaGlide(storySize: StoryDisplay.Size) {
Log.d(TAG, "Image not in cache. Loading via glide...")
GlideApp.with(postImage)
.load(DecryptableStreamUriLoader.DecryptableUri(imagePost.imageUri))
.override(storySize.width, storySize.height)
.centerInside()
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
imageState = LoadState.FAILED
@@ -138,6 +107,16 @@ class StoryImageLoader(
.into(postImage)
}
override fun onBlurLoaded() {
blurState = LoadState.READY
notifyListeners()
}
override fun onBlurFailed() {
blurState = LoadState.FAILED
notifyListeners()
}
private fun notifyListeners() {
if (fragment.isDetached) {
Log.w(TAG, "Fragment is detached, dropping notify call.")
@@ -153,15 +132,15 @@ class StoryImageLoader(
}
}
private inner class OnDestroy(private val onDestroy: () -> Unit) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
onDestroy()
}
}
private enum class LoadState {
INIT,
READY,
FAILED
}
private inner class OnDestroy(private val onDestroy: () -> Unit) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
onDestroy()
}
}
}

View File

@@ -110,12 +110,23 @@ class StoryPostFragment : Fragment(R.layout.stories_post_fragment) {
presentNone()
binding.video.visible = true
binding.blur.visible = true
val storyBlurLoader = StoryBlurLoader(
viewLifecycleOwner.lifecycle,
state.blurHash,
state.videoUri,
pageViewModel.storyCache,
StoryDisplay.getStorySize(resources),
binding.blur
)
storyVideoLoader = StoryVideoLoader(
this,
state,
binding.video,
requireCallback()
requireCallback(),
storyBlurLoader
)
storyVideoLoader?.load()

View File

@@ -24,7 +24,8 @@ sealed class StoryPostState {
val videoUri: Uri,
val size: Long,
val clipStart: Duration,
val clipEnd: Duration
val clipEnd: Duration,
val blurHash: BlurHash?
) : StoryPostState()
data class None(private val ts: Long = System.currentTimeMillis()) : StoryPostState()

View File

@@ -44,7 +44,8 @@ class StoryPostViewModel(private val repository: StoryTextPostRepository) : View
videoUri = storyPostContent.uri,
size = storyPostContent.attachment.size,
clipStart = storyPostContent.attachment.transformProperties.videoTrimStartTimeUs.microseconds,
clipEnd = storyPostContent.attachment.transformProperties.videoTrimEndTimeUs.microseconds
clipEnd = storyPostContent.attachment.transformProperties.videoTrimEndTimeUs.microseconds,
blurHash = storyPostContent.attachment.blurHash
)
}
} else {

View File

@@ -13,7 +13,8 @@ class StoryVideoLoader(
private val fragment: StoryPostFragment,
private val videoPost: StoryPostState.VideoPost,
private val videoPlayer: VideoPlayer,
private val callback: StoryPostFragment.Callback
private val callback: StoryPostFragment.Callback,
private val blurLoader: StoryBlurLoader
) : DefaultLifecycleObserver {
companion object {
@@ -25,11 +26,13 @@ class StoryVideoLoader(
videoPlayer.setVideoSource(VideoSlide(fragment.requireContext(), videoPost.videoUri, videoPost.size, false), false, TAG, videoPost.clipStart.inWholeMilliseconds, videoPost.clipEnd.inWholeMilliseconds)
videoPlayer.hideControls()
videoPlayer.setKeepContentOnPlayerReset(false)
blurLoader.load()
}
fun clear() {
fragment.viewLifecycleOwner.lifecycle.removeObserver(this)
videoPlayer.stop()
blurLoader.clear()
}
override fun onResume(lifecycleOwner: LifecycleOwner) {

View File

@@ -80,7 +80,7 @@ public final class FeatureFlags {
private static final String SENDER_KEY_MAX_AGE = "android.senderKeyMaxAge";
private static final String RETRY_RECEIPTS = "android.retryReceipts";
private static final String MAX_GROUP_CALL_RING_SIZE = "global.calling.maxGroupCallRingSize";
private static final String GROUP_CALL_RINGING = "android.calling.groupCallRinging";
private static final String GROUP_CALL_RINGING = "android.calling.groupCallRinging.2";
private static final String STORIES_TEXT_FUNCTIONS = "android.stories.text.functions";
private static final String HARDWARE_AEC_BLOCKLIST_MODELS = "android.calling.hardwareAecBlockList";
private static final String SOFTWARE_AEC_BLOCKLIST_MODELS = "android.calling.softwareAecBlockList";
@@ -99,7 +99,6 @@ public final class FeatureFlags {
private static final String RECIPIENT_MERGE_V2 = "android.recipientMergeV2";
private static final String SMS_EXPORTER = "android.sms.exporter.2";
private static final String HIDE_CONTACTS = "android.hide.contacts";
private static final String SMS_EXPORT_MEGAPHONE_DELAY_DAYS = "android.smsExport.megaphoneDelayDays.2";
public static final String CREDIT_CARD_PAYMENTS = "android.credit.card.payments.3";
private static final String PAYMENTS_REQUEST_ACTIVATE_FLOW = "android.payments.requestActivateFlow";
private static final String KEEP_MUTED_CHATS_ARCHIVED = "android.keepMutedChatsArchived";
@@ -108,6 +107,7 @@ public final class FeatureFlags {
public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
private static final String PAYMENTS_IN_CHAT_MESSAGES = "android.payments.inChatMessages";
private static final String CHAT_FILTERS = "android.chat.filters";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -159,7 +159,6 @@ public final class FeatureFlags {
RECIPIENT_MERGE_V2,
SMS_EXPORTER,
HIDE_CONTACTS,
SMS_EXPORT_MEGAPHONE_DELAY_DAYS,
CREDIT_CARD_PAYMENTS,
PAYMENTS_REQUEST_ACTIVATE_FLOW,
KEEP_MUTED_CHATS_ARCHIVED,
@@ -168,7 +167,8 @@ public final class FeatureFlags {
PAYPAL_DISABLED_REGIONS,
KEEP_MUTED_CHATS_ARCHIVED,
CDS_HARD_LIMIT,
PAYMENTS_IN_CHAT_MESSAGES
PAYMENTS_IN_CHAT_MESSAGES,
CHAT_FILTERS
);
@VisibleForTesting
@@ -229,7 +229,6 @@ public final class FeatureFlags {
TELECOM_MODEL_BLOCKLIST,
CAMERAX_MODEL_BLOCKLIST,
RECIPIENT_MERGE_V2,
SMS_EXPORT_MEGAPHONE_DELAY_DAYS,
CREDIT_CARD_PAYMENTS,
PAYMENTS_REQUEST_ACTIVATE_FLOW,
KEEP_MUTED_CHATS_ARCHIVED,
@@ -547,13 +546,6 @@ public final class FeatureFlags {
return getBoolean(HIDE_CONTACTS, false);
}
/**
* Number of days to postpone the sms export megaphone and Phase 1 start.
*/
public static int smsExportMegaphoneDelayDays() {
return getInteger(SMS_EXPORT_MEGAPHONE_DELAY_DAYS, 14);
}
/**
* Whether or not we should allow credit card payments for donations
*
@@ -608,6 +600,13 @@ public final class FeatureFlags {
return getInteger(CDS_HARD_LIMIT, 50_000);
}
/**
* Enables chat filters. Note that this UI is incomplete.
*/
public static boolean chatFilters() {
return getBoolean(CHAT_FILTERS, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View File

@@ -143,14 +143,18 @@ public final class FullscreenHelper {
}
public void toggleUiVisibility() {
int systemUiVisibility = activity.getWindow().getDecorView().getSystemUiVisibility();
if ((systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0) {
if (isSystemUiVisible()) {
showSystemUI();
} else {
hideSystemUI();
}
}
public boolean isSystemUiVisible() {
int systemUiVisibility = activity.getWindow().getDecorView().getSystemUiVisibility();
return (systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0;
}
public void hideSystemUI() {
activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |

View File

@@ -87,7 +87,11 @@ public final class ImageCompressionUtil {
}
private static @NonNull Bitmap.CompressFormat mimeTypeToCompressFormat(@NonNull String mimeType) {
if (MediaUtil.isJpegType(mimeType) || MediaUtil.isHeicType(mimeType) || MediaUtil.isHeifType(mimeType) || MediaUtil.isVideoType(mimeType)) {
if (MediaUtil.isJpegType(mimeType) ||
MediaUtil.isHeicType(mimeType) ||
MediaUtil.isHeifType(mimeType) ||
MediaUtil.isAvifType(mimeType) ||
MediaUtil.isVideoType(mimeType)) {
return Bitmap.CompressFormat.JPEG;
} else {
return Bitmap.CompressFormat.PNG;

View File

@@ -56,6 +56,7 @@ public class MediaUtil {
public static final String IMAGE_JPEG = "image/jpeg";
public static final String IMAGE_HEIC = "image/heic";
public static final String IMAGE_HEIF = "image/heif";
public static final String IMAGE_AVIF = "image/avif";
public static final String IMAGE_WEBP = "image/webp";
public static final String IMAGE_GIF = "image/gif";
public static final String AUDIO_AAC = "audio/aac";
@@ -277,6 +278,10 @@ public class MediaUtil {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIF);
}
public static boolean isAvifType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_AVIF);
}
public static boolean isFile(Attachment attachment) {
return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment);
}

View File

@@ -25,11 +25,23 @@ object SystemWindowInsetsSetter {
insets.bottom
)
} else {
val top = if (insetType and WindowInsetsCompat.Type.statusBars() != 0) {
ViewUtil.getStatusBarHeight(view)
} else {
0
}
val bottom = if (insetType and WindowInsetsCompat.Type.navigationBars() != 0) {
ViewUtil.getNavigationBarHeight(view)
} else {
0
}
view.setPadding(
0,
ViewUtil.getStatusBarHeight(view),
top,
0,
ViewUtil.getNavigationBarHeight(view)
bottom
)
}
}

View File

@@ -0,0 +1,45 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="140dp"
android:height="128dp"
android:viewportWidth="140"
android:viewportHeight="128">
<path
android:pathData="M53.92,13.83L97.42,13.83A14.5,14.5 0,0 1,111.92 28.33L111.92,105.67A14.5,14.5 0,0 1,97.42 120.17L53.92,120.17A14.5,14.5 0,0 1,39.42 105.67L39.42,28.33A14.5,14.5 0,0 1,53.92 13.83z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="75.67"
android:startY="13.83"
android:endX="75.67"
android:endY="120.17"
android:type="linear">
<item android:offset="0" android:color="#FF4437D8"/>
<item android:offset="0.33" android:color="#FF6B70DE"/>
<item android:offset="0.67" android:color="#FFB774E0"/>
<item android:offset="1" android:color="#FFFF8E8E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M33.95,25.81L25.98,28.71C18.45,31.45 14.57,39.77 17.31,47.3L33.95,93.02V25.81Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="25.19"
android:startY="25.81"
android:endX="25.19"
android:endY="93.02"
android:type="linear">
<item android:offset="0" android:color="#FF4437D8"/>
<item android:offset="0.33" android:color="#FF6B70DE"/>
<item android:offset="0.67" android:color="#FFB774E0"/>
<item android:offset="1" android:color="#FFFF8E8E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M86.55,63.1C88.62,63.1 90.6,63.93 92.06,65.39C93.52,66.85 94.35,68.83 94.35,70.9V71.8H96.15V70.9C96.15,68.63 95.34,66.43 93.87,64.69C92.39,62.96 90.35,61.81 88.11,61.44C89.3,60.39 90.14,59.01 90.52,57.47C90.9,55.93 90.81,54.31 90.25,52.83C89.69,51.35 88.69,50.07 87.38,49.17C86.08,48.27 84.53,47.79 82.95,47.79C81.37,47.79 79.82,48.27 78.52,49.17C77.21,50.07 76.21,51.35 75.65,52.83C75.09,54.31 75,55.93 75.38,57.47C75.76,59.01 76.6,60.39 77.79,61.44C76.64,61.62 75.54,62.02 74.54,62.6C73.85,61.77 73,61.09 72.04,60.6C71.08,60.11 70.02,59.82 68.95,59.76C67.87,59.7 66.79,59.87 65.78,60.24C64.77,60.62 63.84,61.21 63.07,61.96C62.3,62.71 61.69,63.62 61.28,64.62C60.87,65.62 60.68,66.69 60.71,67.77C60.74,68.85 60.99,69.91 61.46,70.88C61.92,71.86 62.58,72.73 63.39,73.44C61.15,73.81 59.11,74.96 57.63,76.69C56.16,78.43 55.35,80.63 55.35,82.9V83.8H57.15V82.9C57.15,80.83 57.98,78.85 59.44,77.39C60.9,75.93 62.88,75.1 64.95,75.1H72.15C74.22,75.1 76.2,75.93 77.66,77.39C79.12,78.85 79.95,80.83 79.95,82.9V83.8H81.75V82.9C81.75,80.63 80.94,78.43 79.47,76.69C77.99,74.96 75.95,73.81 73.71,73.44C75,72.3 75.88,70.77 76.21,69.08C76.54,67.4 76.29,65.65 75.52,64.11C76.69,63.45 78.01,63.1 79.35,63.1H86.55ZM68.55,73.6C67.36,73.6 66.2,73.25 65.22,72.59C64.23,71.93 63.46,70.99 63.01,69.9C62.55,68.8 62.43,67.59 62.67,66.43C62.9,65.27 63.47,64.2 64.31,63.36C65.15,62.52 66.22,61.95 67.38,61.72C68.54,61.48 69.75,61.6 70.85,62.06C71.94,62.51 72.88,63.28 73.54,64.27C74.2,65.25 74.55,66.41 74.55,67.6C74.55,69.19 73.92,70.72 72.79,71.84C71.67,72.97 70.14,73.6 68.55,73.6V73.6ZM76.95,55.6C76.95,54.41 77.3,53.25 77.96,52.27C78.62,51.28 79.56,50.51 80.65,50.06C81.75,49.6 82.96,49.48 84.12,49.72C85.28,49.95 86.35,50.52 87.19,51.36C88.03,52.2 88.6,53.27 88.83,54.43C89.07,55.59 88.95,56.8 88.49,57.9C88.04,58.99 87.27,59.93 86.28,60.59C85.3,61.25 84.14,61.6 82.95,61.6C81.36,61.6 79.83,60.97 78.71,59.84C77.58,58.72 76.95,57.19 76.95,55.6Z"
android:strokeWidth="0.5"
android:fillColor="#ffffff"
android:strokeColor="#ffffff"/>
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient android:type="linear"
android:angle="90"
android:startColor="@color/story_caption_gradient_start" />
</shape>

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