mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-16 07:57:38 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245f7d3e03 | ||
|
|
972ce41689 | ||
|
|
be12a17ff7 | ||
|
|
0c615e2fc2 | ||
|
|
6829257a83 | ||
|
|
b7b7a04fad | ||
|
|
50084f8f73 | ||
|
|
04e8235cfc | ||
|
|
0df3096241 | ||
|
|
29f22d515a | ||
|
|
9931496b0f | ||
|
|
950363a4e9 | ||
|
|
3469e8d0e0 | ||
|
|
586339575f | ||
|
|
807a0e02a2 | ||
|
|
afb2b1a1a2 | ||
|
|
a8946961d5 | ||
|
|
026aaac451 | ||
|
|
159f319d77 | ||
|
|
cf00995b6f | ||
|
|
7c60c32918 | ||
|
|
fd1d2ec8fc | ||
|
|
a11c40e4fe | ||
|
|
1eb2f51398 | ||
|
|
13ed122c3e | ||
|
|
fa02ee1d3d | ||
|
|
4908e39308 | ||
|
|
ad001d585e | ||
|
|
3fd5e55363 | ||
|
|
ebc1bc3f7f | ||
|
|
c51e13fd30 | ||
|
|
fd37613f2f | ||
|
|
eb921f3103 | ||
|
|
d5b6c47670 | ||
|
|
a4494b58f0 | ||
|
|
b0c68b12ed | ||
|
|
b47e5f2fa9 | ||
|
|
bba1315906 | ||
|
|
3e2ecdaaa9 | ||
|
|
fb8e81cf50 | ||
|
|
52a5fb8ea2 | ||
|
|
b2f3867b0b | ||
|
|
45ca3bd7cf | ||
|
|
74b7057608 | ||
|
|
3a060c7a79 | ||
|
|
de426d22bf | ||
|
|
14549fd401 |
10
README.md
10
README.md
@@ -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. Signal’s 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
|
||||
====
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ object DonationPillToggle {
|
||||
DonateToSignalType.MONTHLY -> {
|
||||
presentButtons(model, binding.monthly, binding.oneTime)
|
||||
}
|
||||
DonateToSignalType.GIFT -> {
|
||||
error("Unsupported donation type.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ public class Conversation {
|
||||
THREAD,
|
||||
PINNED_HEADER,
|
||||
UNPINNED_HEADER,
|
||||
ARCHIVED_FOOTER
|
||||
ARCHIVED_FOOTER,
|
||||
CONVERSATION_FILTER_FOOTER,
|
||||
CONVERSATION_FILTER_EMPTY,
|
||||
EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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, _ ->
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ class Camera1Controller {
|
||||
});
|
||||
}
|
||||
|
||||
boolean isCameraFacingFront() {
|
||||
return cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT;
|
||||
}
|
||||
|
||||
int flip() {
|
||||
Log.d(TAG, "flip()");
|
||||
SurfaceTexture surfaceTexture = previewSurface;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
45
app/src/main/res/drawable/group_story.xml
Normal file
45
app/src/main/res/drawable/group_story.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/story_caption_gradient.xml
Normal file
9
app/src/main/res/drawable/story_caption_gradient.xml
Normal 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
Reference in New Issue
Block a user