mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-11 20:43:34 +01:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551e5a0a25 | ||
|
|
92d4a580c1 | ||
|
|
b367701a96 | ||
|
|
8595863afe | ||
|
|
21492ed88e | ||
|
|
4dc14ab7f9 | ||
|
|
5caf3409db | ||
|
|
1565c32162 | ||
|
|
45edb4e5da | ||
|
|
5bf1c4f433 | ||
|
|
3cc692d3fb | ||
|
|
e42b2490f0 | ||
|
|
454b1f69ed | ||
|
|
b410756dfd | ||
|
|
1458919549 | ||
|
|
48ae8c2465 | ||
|
|
0a78bcb374 | ||
|
|
61cdb48273 | ||
|
|
b3350b22b6 | ||
|
|
d35d22c7d8 | ||
|
|
24cd11152b | ||
|
|
d21254ac02 | ||
|
|
70f08c806a | ||
|
|
e7c3fb02e8 | ||
|
|
3d3cf1d76e | ||
|
|
2bf385fe38 | ||
|
|
7ba595be55 | ||
|
|
c45e79c588 | ||
|
|
f37568b050 | ||
|
|
b5afc1cd1c | ||
|
|
e9777ccfc6 | ||
|
|
898404fc65 | ||
|
|
131212b158 | ||
|
|
3f1d3149e9 | ||
|
|
bfc8b199b6 | ||
|
|
6d4b487428 | ||
|
|
9337201ffb | ||
|
|
494b2c6786 | ||
|
|
bc1c8032c1 | ||
|
|
21b0a4d370 | ||
|
|
133effccfc | ||
|
|
62b4ebc4a9 | ||
|
|
12941ea19e | ||
|
|
f94bd706a4 | ||
|
|
3cbbc29c00 | ||
|
|
0827c18eeb | ||
|
|
6c4ebc9f58 | ||
|
|
1f2bfe8245 | ||
|
|
305d7485c1 | ||
|
|
4ded05bbd1 | ||
|
|
540a2b1876 | ||
|
|
153d3ad388 | ||
|
|
a3e36d2453 | ||
|
|
b9449a798b | ||
|
|
9da149a868 | ||
|
|
d505c00403 | ||
|
|
4d7a0a361f | ||
|
|
e08e02ae80 | ||
|
|
95c6f569d6 | ||
|
|
e46759f436 | ||
|
|
b42dd5289b | ||
|
|
a911a007d2 | ||
|
|
64babe2e42 | ||
|
|
099c94c215 | ||
|
|
75b81a0fd2 | ||
|
|
f9ab5d4013 | ||
|
|
b83080e2d7 | ||
|
|
6a21106347 | ||
|
|
9a7d8c858d | ||
|
|
8339c0d8de | ||
|
|
2b1136ea02 | ||
|
|
84b4d69913 | ||
|
|
3fe9ce378e | ||
|
|
57b9571d86 | ||
|
|
ae3071d318 | ||
|
|
8a93814bac | ||
|
|
a6dd4345ab | ||
|
|
c71456444f | ||
|
|
b916605a24 | ||
|
|
553da1e7e8 | ||
|
|
847651ead7 | ||
|
|
f977f261d6 | ||
|
|
3fa9e89e8e | ||
|
|
0662959e1d | ||
|
|
e5e03f9693 | ||
|
|
4203900365 | ||
|
|
eb7794ba47 | ||
|
|
9626f33768 | ||
|
|
cfc0ace41e | ||
|
|
ce2947c756 | ||
|
|
87fc10ad24 | ||
|
|
cae71559a0 | ||
|
|
3cf7920a22 | ||
|
|
fba9b46fe9 | ||
|
|
611f074a9d | ||
|
|
7909703f4c | ||
|
|
dcbf4b8faf | ||
|
|
c5edcf47bd | ||
|
|
02e6b89fdd | ||
|
|
c4109a19d6 | ||
|
|
630d9492cd | ||
|
|
b762d95622 | ||
|
|
3738997832 | ||
|
|
21c70039f4 | ||
|
|
23e3385290 | ||
|
|
4ab82c99a8 | ||
|
|
f4df37da23 | ||
|
|
4494d8652d | ||
|
|
32ae4393e2 | ||
|
|
ea5c3a7c5e | ||
|
|
f9d9af4fe9 | ||
|
|
098ef61b5d | ||
|
|
e926f56f6b | ||
|
|
9b1da3cfa0 | ||
|
|
1fbcd9b362 | ||
|
|
38940e0111 | ||
|
|
4fa3570d1e | ||
|
|
d1c78d5062 | ||
|
|
c4862bdddf | ||
|
|
2b8018727c | ||
|
|
e3be279f1f | ||
|
|
1e6126d5be | ||
|
|
9a09708842 | ||
|
|
e861204cb0 | ||
|
|
afd3afcf0d | ||
|
|
5055b0c75d | ||
|
|
372104cdfe | ||
|
|
acb24fd265 | ||
|
|
5b7420ba90 | ||
|
|
e73dbd5c15 | ||
|
|
b5f82beb46 | ||
|
|
61b97fd09b | ||
|
|
99e34860d4 | ||
|
|
5d44bbe956 | ||
|
|
e7d0b575bb | ||
|
|
8b2a535f19 | ||
|
|
a242dba345 | ||
|
|
587cb5de16 | ||
|
|
e93c6957ac | ||
|
|
f644115b54 | ||
|
|
0c753d22b6 | ||
|
|
ec7f2c33e7 | ||
|
|
39c1c1e371 | ||
|
|
74d5faf3fa | ||
|
|
15204a2c84 | ||
|
|
2397cb5428 | ||
|
|
4b6b87d632 | ||
|
|
2492b8de34 | ||
|
|
635987a420 | ||
|
|
51602ed231 | ||
|
|
25aab0f702 | ||
|
|
23b3c7f1fd | ||
|
|
451ce74fa4 | ||
|
|
1fd9609810 | ||
|
|
29804e0a2b | ||
|
|
26aa7e8332 | ||
|
|
e4e00be119 | ||
|
|
de6b71528b | ||
|
|
d005ace383 | ||
|
|
f566e10710 | ||
|
|
18f9c6b1f0 | ||
|
|
fbf4de0ec5 | ||
|
|
3d94122abc | ||
|
|
442a66df2e | ||
|
|
3be5d61ced | ||
|
|
f137e23b43 | ||
|
|
f00178cc0d | ||
|
|
e33c5b055d | ||
|
|
f2237a385e | ||
|
|
a9c45f7e78 | ||
|
|
11cfe5ee82 | ||
|
|
4cbcee85d6 | ||
|
|
98ec2cceb4 | ||
|
|
8ce05c8bbe | ||
|
|
a7019b2e60 | ||
|
|
0facdc0497 | ||
|
|
25a7560e2e | ||
|
|
063d909572 | ||
|
|
2f8e112f3a | ||
|
|
99abfd0d98 | ||
|
|
5fa9a27ee0 | ||
|
|
b07d675bb4 | ||
|
|
9f75c37331 | ||
|
|
df96b05863 | ||
|
|
d6adfea9b1 | ||
|
|
389b439e9a | ||
|
|
046b89fa21 | ||
|
|
72e5532c6c | ||
|
|
5688d85789 | ||
|
|
28b63e08f1 | ||
|
|
951ce77853 | ||
|
|
b37ba63018 | ||
|
|
251d251661 |
@@ -10,7 +10,6 @@ plugins {
|
||||
id 'app.cash.exhaustive'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.squareup.wire'
|
||||
id 'android-constants'
|
||||
id 'translations'
|
||||
}
|
||||
|
||||
@@ -39,14 +38,18 @@ wire {
|
||||
sourcePath {
|
||||
srcDir 'src/main/protowire'
|
||||
}
|
||||
|
||||
protoPath {
|
||||
srcDir "${project.rootDir}/libsignal/service/src/main/protowire"
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1312
|
||||
def canonicalVersionName = "6.29.1"
|
||||
def canonicalVersionCode = 1326
|
||||
def canonicalVersionName = "6.32.3"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -94,7 +97,7 @@ android {
|
||||
testBuildType 'instrumentation'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
@@ -181,6 +184,7 @@ android {
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
@@ -195,6 +199,7 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
|
||||
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
@@ -377,6 +382,7 @@ android {
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
@@ -511,7 +517,7 @@ dependencies {
|
||||
implementation libs.google.play.services.maps
|
||||
implementation libs.google.play.services.auth
|
||||
|
||||
implementation libs.bundles.exoplayer
|
||||
implementation libs.bundles.media3
|
||||
|
||||
implementation libs.conscrypt.android
|
||||
implementation libs.signal.aesgcmprovider
|
||||
@@ -590,6 +596,7 @@ dependencies {
|
||||
testImplementation testLibs.robolectric.shadows.multidex
|
||||
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
|
||||
testImplementation (testLibs.bouncycastle.bcpkix.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
|
||||
testImplementation testLibs.conscrypt.openjdk.uber // Used by robolectric
|
||||
testImplementation testLibs.hamcrest.hamcrest
|
||||
testImplementation testLibs.mockk
|
||||
|
||||
|
||||
BIN
app/src/androidTest/assets/images/sample_image.png
Normal file
BIN
app/src/androidTest/assets/images/sample_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -50,7 +50,6 @@ class ChangeNumberViewModelTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
ApplicationDependencies.getSignalServiceAccountManager().setSoTimeoutMillis(1000)
|
||||
ThreadUtil.runOnMainSync {
|
||||
viewModel = ChangeNumberViewModel(
|
||||
localNumber = harness.self.requireE164(),
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -214,6 +216,8 @@ class V2ConversationItemShapeTest {
|
||||
override val selectedItems: Set<MultiselectPart> = emptySet()
|
||||
override val isMessageRequestAccepted: Boolean = true
|
||||
override val searchQuery: String? = null
|
||||
override val glideRequests: GlideRequests = mockk()
|
||||
override val isParentInScroll: Boolean = false
|
||||
|
||||
override fun onStartExpirationTimeout(messageRecord: MessageRecord) = Unit
|
||||
|
||||
|
||||
@@ -134,6 +134,40 @@ class AttachmentTableTest {
|
||||
highInfo.file.exists() assertIs true
|
||||
}
|
||||
|
||||
/**
|
||||
* Given: Three pre-upload attachments with the same data but different transform properties (1x standard and 2x high).
|
||||
*
|
||||
* When inserting content of high pre-upload attachment.
|
||||
*
|
||||
* Then do not deduplicate with standard pre-upload attachment, but do deduplicate second high insert.
|
||||
*/
|
||||
@Test
|
||||
fun doNotDedupedFileIfUsedByAnotherAttachmentWithADifferentTransformProperties() {
|
||||
// GIVEN
|
||||
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
|
||||
|
||||
val standardQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.empty())
|
||||
val standardDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(standardQualityPreUpload)
|
||||
|
||||
// WHEN
|
||||
val highQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
|
||||
val highDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityPreUpload)
|
||||
|
||||
val secondHighQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
|
||||
val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload)
|
||||
|
||||
// THEN
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
|
||||
highInfo.file assertIsNot standardInfo.file
|
||||
secondHighInfo.file assertIs highInfo.file
|
||||
standardInfo.file.exists() assertIs true
|
||||
highInfo.file.exists() assertIs true
|
||||
}
|
||||
|
||||
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
|
||||
return UriAttachmentBuilder.build(
|
||||
id,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.assertNull
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireLongOrNull
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.UUID
|
||||
|
||||
class KyberPreKeyTableTest {
|
||||
|
||||
private val aci: ACI = ACI.from(UUID.randomUUID())
|
||||
private val pni: PNI = PNI.from(UUID.randomUUID())
|
||||
|
||||
@Test
|
||||
fun markAllStaleIfNecessary_onlyUpdatesMatchingAccountAndZeroValues() {
|
||||
insertTestRecord(aci, id = 1)
|
||||
insertTestRecord(aci, id = 2)
|
||||
insertTestRecord(aci, id = 3, staleTime = 42)
|
||||
insertTestRecord(pni, id = 4)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.kyberPreKeys.markAllStaleIfNecessary(aci, now)
|
||||
|
||||
assertEquals(now, getStaleTime(aci, 1))
|
||||
assertEquals(now, getStaleTime(aci, 2))
|
||||
assertEquals(42L, getStaleTime(aci, 3))
|
||||
assertEquals(0L, getStaleTime(pni, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_deleteOldBeforeThreshold() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
insertTestRecord(aci, id = 4, staleTime = 15)
|
||||
insertTestRecord(aci, id = 5, staleTime = 0)
|
||||
|
||||
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNull(getStaleTime(aci, 2))
|
||||
assertNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_neverDeleteStaleOfZero() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 0)
|
||||
insertTestRecord(aci, id = 2, staleTime = 0)
|
||||
insertTestRecord(aci, id = 3, staleTime = 0)
|
||||
insertTestRecord(aci, id = 4, staleTime = 0)
|
||||
insertTestRecord(aci, id = 5, staleTime = 0)
|
||||
|
||||
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 10, minCount = 1)
|
||||
|
||||
assertNotNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_respectMinCount() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
insertTestRecord(aci, id = 4, staleTime = 10)
|
||||
insertTestRecord(aci, id = 5, staleTime = 10)
|
||||
|
||||
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_respectAccount() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
|
||||
insertTestRecord(pni, id = 4, staleTime = 10)
|
||||
insertTestRecord(pni, id = 5, staleTime = 10)
|
||||
|
||||
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 2)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(pni, 4))
|
||||
assertNotNull(getStaleTime(pni, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_ignoreLastResortForMinCount() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
insertTestRecord(aci, id = 4, staleTime = 10)
|
||||
insertTestRecord(aci, id = 5, staleTime = 10, lastResort = true)
|
||||
|
||||
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_neverDeleteLastResort() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = true)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10, lastResort = true)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10, lastResort = true)
|
||||
|
||||
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
|
||||
|
||||
assertNotNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
}
|
||||
|
||||
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
|
||||
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
|
||||
SignalDatabase.kyberPreKeys.insert(
|
||||
serviceId = account,
|
||||
keyId = id,
|
||||
record = KyberPreKeyRecord(
|
||||
id,
|
||||
System.currentTimeMillis(),
|
||||
kemKeyPair,
|
||||
Curve.generateKeyPair().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
|
||||
),
|
||||
lastResort = lastResort
|
||||
)
|
||||
|
||||
val count = SignalDatabase.rawDatabase
|
||||
.update(KyberPreKeyTable.TABLE_NAME)
|
||||
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account)
|
||||
.run()
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
private fun getStaleTime(account: ServiceId, id: Int): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(KyberPreKeyTable.STALE_TIMESTAMP)
|
||||
.from(KyberPreKeyTable.TABLE_NAME)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account)
|
||||
.run()
|
||||
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.assertNull
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireLongOrNull
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.UUID
|
||||
|
||||
class OneTimePreKeyTableTest {
|
||||
|
||||
private val aci: ACI = ACI.from(UUID.randomUUID())
|
||||
private val pni: PNI = PNI.from(UUID.randomUUID())
|
||||
|
||||
@Test
|
||||
fun markAllStaleIfNecessary_onlyUpdatesMatchingAccountAndZeroValues() {
|
||||
insertTestRecord(aci, id = 1)
|
||||
insertTestRecord(aci, id = 2)
|
||||
insertTestRecord(aci, id = 3, staleTime = 42)
|
||||
insertTestRecord(pni, id = 4)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.oneTimePreKeys.markAllStaleIfNecessary(aci, now)
|
||||
|
||||
assertEquals(now, getStaleTime(aci, 1))
|
||||
assertEquals(now, getStaleTime(aci, 2))
|
||||
assertEquals(42L, getStaleTime(aci, 3))
|
||||
assertEquals(0L, getStaleTime(pni, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_deleteOldBeforeThreshold() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
insertTestRecord(aci, id = 4, staleTime = 15)
|
||||
insertTestRecord(aci, id = 5, staleTime = 0)
|
||||
|
||||
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNull(getStaleTime(aci, 2))
|
||||
assertNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_neverDeleteStaleOfZero() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 0)
|
||||
insertTestRecord(aci, id = 2, staleTime = 0)
|
||||
insertTestRecord(aci, id = 3, staleTime = 0)
|
||||
insertTestRecord(aci, id = 4, staleTime = 0)
|
||||
insertTestRecord(aci, id = 5, staleTime = 0)
|
||||
|
||||
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 10, minCount = 0)
|
||||
|
||||
assertNotNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_respectMinCount() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
insertTestRecord(aci, id = 4, staleTime = 10)
|
||||
insertTestRecord(aci, id = 5, staleTime = 10)
|
||||
|
||||
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(aci, 4))
|
||||
assertNotNull(getStaleTime(aci, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllStaleBefore_respectAccount() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10)
|
||||
insertTestRecord(aci, id = 2, staleTime = 10)
|
||||
insertTestRecord(aci, id = 3, staleTime = 10)
|
||||
|
||||
insertTestRecord(pni, id = 4, staleTime = 10)
|
||||
insertTestRecord(pni, id = 5, staleTime = 10)
|
||||
|
||||
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 2)
|
||||
|
||||
assertNull(getStaleTime(aci, 1))
|
||||
assertNotNull(getStaleTime(aci, 2))
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
assertNotNull(getStaleTime(pni, 4))
|
||||
assertNotNull(getStaleTime(pni, 5))
|
||||
}
|
||||
|
||||
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0) {
|
||||
SignalDatabase.oneTimePreKeys.insert(
|
||||
serviceId = account,
|
||||
keyId = id,
|
||||
record = PreKeyRecord(id, Curve.generateKeyPair())
|
||||
)
|
||||
|
||||
val count = SignalDatabase.rawDatabase
|
||||
.update(OneTimePreKeyTable.TABLE_NAME)
|
||||
.values(OneTimePreKeyTable.STALE_TIMESTAMP to staleTime)
|
||||
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account)
|
||||
.run()
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
private fun getStaleTime(account: ServiceId, id: Int): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(OneTimePreKeyTable.STALE_TIMESTAMP)
|
||||
.from(OneTimePreKeyTable.TABLE_NAME)
|
||||
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account)
|
||||
.run()
|
||||
.readToSingleObject { it.requireLongOrNull(OneTimePreKeyTable.STALE_TIMESTAMP) }
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
|
||||
|
||||
val newProto = oldRecord
|
||||
.toProto()
|
||||
.toBuilder()
|
||||
.setIdentityState(ContactRecord.IdentityState.DEFAULT)
|
||||
.newBuilder()
|
||||
.identityState(ContactRecord.IdentityState.DEFAULT)
|
||||
.build()
|
||||
val newRecord = SignalContactRecord(oldRecord.id, newProto)
|
||||
|
||||
|
||||
@@ -60,21 +60,6 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun single() {
|
||||
test("merge, e164 + pni reassigned, aci abandoned") {
|
||||
given(E164_A, PNI_A, ACI_A)
|
||||
given(E164_B, PNI_B, ACI_B)
|
||||
|
||||
process(E164_A, PNI_A, ACI_B)
|
||||
|
||||
expect(null, null, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_B)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allNonMergeTests() {
|
||||
test("e164-only insert") {
|
||||
@@ -85,7 +70,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
assertEquals(RecipientTable.RegisteredState.UNKNOWN, record.registered)
|
||||
}
|
||||
|
||||
test("pni-only insert", exception = IllegalArgumentException::class.java) {
|
||||
test("pni-only insert") {
|
||||
val id = process(null, PNI_A, null)
|
||||
expect(null, PNI_A, null)
|
||||
|
||||
@@ -102,18 +87,27 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
|
||||
test("e164+pni insert") {
|
||||
process(E164_A, PNI_A, null)
|
||||
val id = process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
|
||||
test("e164+aci insert") {
|
||||
process(E164_A, null, ACI_A)
|
||||
val id = process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
|
||||
test("e164+pni+aci insert") {
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
val id = process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +118,9 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, null, null)
|
||||
}
|
||||
|
||||
test("no match, e164 and pni") {
|
||||
process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
test("no match, pni-only") {
|
||||
process(null, PNI_A, null)
|
||||
expect(null, PNI_A, null)
|
||||
}
|
||||
|
||||
test("no match, aci-only") {
|
||||
@@ -134,6 +128,11 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(null, null, ACI_A)
|
||||
}
|
||||
|
||||
test("no match, e164 and pni") {
|
||||
process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
}
|
||||
|
||||
test("no match, e164 and aci") {
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
@@ -410,7 +409,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, e164 & pni+e164, no aci provided") {
|
||||
test("steal, e164 & pni+e164, no aci provided, pni session exists") {
|
||||
val id1 = given(E164_A, null, null)
|
||||
val id2 = given(E164_B, PNI_A, null, pniSession = true)
|
||||
|
||||
@@ -423,6 +422,16 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectSessionSwitchoverEvent(id2, E164_B)
|
||||
}
|
||||
|
||||
test("steal, e164 & pni+e164, no aci provided, no pni session") {
|
||||
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("steal, e164+pni+aci & e164+aci, no pni provided, change number") {
|
||||
given(E164_A, PNI_A, ACI_A)
|
||||
given(E164_B, null, ACI_B)
|
||||
@@ -435,6 +444,64 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+aci & aci, no pni provided, existing aci session") {
|
||||
given(E164_A, null, ACI_A, aciSession = true)
|
||||
given(null, null, ACI_B)
|
||||
|
||||
process(E164_A, null, ACI_B)
|
||||
|
||||
expect(null, null, ACI_A)
|
||||
expect(E164_A, null, ACI_B)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni+aci & aci, no pni provided, existing aci session") {
|
||||
given(E164_A, PNI_A, ACI_A, aciSession = true)
|
||||
given(null, null, ACI_B)
|
||||
|
||||
process(E164_A, null, ACI_B)
|
||||
|
||||
expect(null, PNI_A, ACI_A)
|
||||
expect(E164_A, null, ACI_B)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni+aci & aci, no pni provided, existing pni session") {
|
||||
given(E164_A, PNI_A, ACI_A, pniSession = true)
|
||||
given(null, null, ACI_B)
|
||||
|
||||
process(E164_A, null, ACI_B)
|
||||
|
||||
expect(null, PNI_A, ACI_A)
|
||||
expect(E164_A, null, ACI_B)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni & aci, no pni provided, no pni session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, null, ACI_A)
|
||||
|
||||
expect(null, PNI_A, null)
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("steal, e164+pni & aci, no pni provided, pni session") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, null, ACI_A)
|
||||
|
||||
expect(null, PNI_A, null)
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
@@ -690,6 +757,22 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
process(E164_A, null, ACI_SELF, changeSelf = true)
|
||||
expect(E164_A, null, ACI_SELF)
|
||||
}
|
||||
|
||||
test("local user, local e164+aci provided, changeSelf=false, leave pni alone") {
|
||||
given(E164_SELF, PNI_SELF, ACI_SELF)
|
||||
|
||||
process(E164_SELF, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_SELF, PNI_SELF, ACI_SELF)
|
||||
}
|
||||
|
||||
test("local user, local e164+aci provided, changeSelf=false, leave pni alone") {
|
||||
given(E164_SELF, PNI_A, ACI_SELF)
|
||||
|
||||
process(E164_SELF, PNI_SELF, ACI_A)
|
||||
|
||||
expect(E164_SELF, PNI_A, ACI_SELF)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -942,7 +1025,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
pni: PNI?,
|
||||
aci: ACI?,
|
||||
createThread: Boolean = true,
|
||||
pniSession: Boolean = false
|
||||
pniSession: Boolean = false,
|
||||
aciSession: Boolean = false
|
||||
): RecipientId {
|
||||
val id = insert(e164, pni, aci)
|
||||
generatedIds += id
|
||||
@@ -960,6 +1044,14 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
SignalDatabase.sessions.store(pni, SignalProtocolAddress(pni.toString(), 1), SessionRecord())
|
||||
}
|
||||
|
||||
if (aciSession) {
|
||||
if (aci == null) {
|
||||
throw IllegalArgumentException("aciSession = true but aci is null!")
|
||||
}
|
||||
|
||||
SignalDatabase.sessions.store(aci, SignalProtocolAddress(aci.toString(), 1), SessionRecord())
|
||||
}
|
||||
|
||||
if (aci != null) {
|
||||
SignalDatabase.identities.saveIdentity(
|
||||
addressName = aci.toString(),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.UriAttachmentBuilder
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AttachmentCompressionJobTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun testCompressionJobsWithDifferentTransformPropertiesCompleteSuccessfully() {
|
||||
val imageBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("images/sample_image.png").use {
|
||||
StreamUtil.readFully(it)
|
||||
}
|
||||
|
||||
val blob = BlobProvider.getInstance().forData(imageBytes).createForSingleSessionOnDisk(ApplicationDependencies.getApplication())
|
||||
|
||||
val firstPreUpload = createAttachment(1, blob, AttachmentTable.TransformProperties.empty())
|
||||
val firstDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(firstPreUpload)
|
||||
|
||||
val firstCompressionJob: AttachmentCompressionJob = AttachmentCompressionJob.fromAttachment(firstDatabaseAttachment, false, -1)
|
||||
|
||||
var secondCompressionJob: AttachmentCompressionJob? = null
|
||||
var firstJobResult: Job.Result? = null
|
||||
var secondJobResult: Job.Result? = null
|
||||
|
||||
val secondJobLatch = CountDownLatch(1)
|
||||
val jobThread = Thread {
|
||||
firstCompressionJob.setContext(ApplicationDependencies.getApplication())
|
||||
firstJobResult = firstCompressionJob.run()
|
||||
|
||||
secondJobLatch.await()
|
||||
|
||||
secondCompressionJob!!.setContext(ApplicationDependencies.getApplication())
|
||||
secondJobResult = secondCompressionJob!!.run()
|
||||
}
|
||||
|
||||
jobThread.start()
|
||||
val secondPreUpload = createAttachment(1, blob, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
|
||||
val secondDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondPreUpload)
|
||||
secondCompressionJob = AttachmentCompressionJob.fromAttachment(secondDatabaseAttachment, false, -1)
|
||||
|
||||
secondJobLatch.countDown()
|
||||
|
||||
jobThread.join()
|
||||
|
||||
firstJobResult!!.isSuccess assertIs true
|
||||
secondJobResult!!.isSuccess assertIs true
|
||||
}
|
||||
|
||||
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
|
||||
return UriAttachmentBuilder.build(
|
||||
id,
|
||||
uri = uri,
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
transformProperties = transformProperties
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
|
||||
SignalStore.account().usernameOutOfSync = false
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,7 +78,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +108,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -142,7 +142,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
// THEN
|
||||
assertFalse(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -176,6 +176,6 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertTrue(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,13 @@ class EditMessageSyncProcessorTest {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var processorV2: MessageContentProcessorV2
|
||||
private lateinit var processorV2: MessageContentProcessor
|
||||
private lateinit var testResult: TestResults
|
||||
private var envelopeTimestamp: Long = 0
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
processorV2 = MessageContentProcessorV2(harness.context)
|
||||
processorV2 = MessageContentProcessor(harness.context)
|
||||
envelopeTimestamp = System.currentTimeMillis()
|
||||
testResult = TestResults()
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Rule
|
||||
import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata
|
||||
import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.TestProtos
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
|
||||
abstract class MessageContentProcessorTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
protected fun MessageContentProcessor.doProcess(
|
||||
messageState: MessageState = MessageState.DECRYPTED_OK,
|
||||
content: SignalServiceContent,
|
||||
exceptionMetadata: ExceptionMetadata = ExceptionMetadata("sender", 1),
|
||||
timestamp: Long = 100L,
|
||||
smsMessageId: Long = -1L
|
||||
) {
|
||||
process(messageState, content, exceptionMetadata, timestamp, smsMessageId)
|
||||
}
|
||||
|
||||
protected fun createNormalContentTestSubject(): MessageContentProcessor {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
|
||||
return MessageContentProcessor.create(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid ServiceContentProto with a data message which can be built via
|
||||
* `injectDataMessage`. This function is intended to be built on-top of for more
|
||||
* specific scenario in subclasses.
|
||||
*
|
||||
* Example can be seen in __handleStoryMessageTest
|
||||
*/
|
||||
protected fun createServiceContentWithDataMessage(
|
||||
messageSender: Recipient = Recipient.resolved(harness.others.first()),
|
||||
injectDataMessage: SignalServiceProtos.DataMessage.Builder.() -> Unit
|
||||
): SignalServiceContentProto {
|
||||
return TestProtos.build {
|
||||
serviceContent(
|
||||
localAddress = address(uuid = harness.self.requireServiceId().rawUuid).build(),
|
||||
metadata = metadata(
|
||||
address = address(uuid = messageSender.requireServiceId().rawUuid).build()
|
||||
).build()
|
||||
).apply {
|
||||
content = content().apply {
|
||||
dataMessage = dataMessage().apply {
|
||||
injectDataMessage()
|
||||
}.build()
|
||||
}.build()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MmsHelper
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.TestProtos
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("ClassName")
|
||||
class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorTest() {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.messages.deleteAllThreads()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
SignalDatabase.messages.deleteAllThreads()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenContentWithADirectStoryReplyWhenIProcessThenIInsertAReplyInTheCorrectThread() {
|
||||
val sender = Recipient.resolved(harness.others.first())
|
||||
val senderThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(sender)
|
||||
val myStory = Recipient.resolved(SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!!)
|
||||
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
val expectedSentTime = 200L
|
||||
val storyMessageId = MmsHelper.insert(
|
||||
sentTimeMillis = expectedSentTime,
|
||||
recipient = myStory,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = myStoryThread
|
||||
)
|
||||
|
||||
SignalDatabase.storySends.insert(
|
||||
messageId = storyMessageId,
|
||||
recipientIds = listOf(sender.id),
|
||||
sentTimestamp = expectedSentTime,
|
||||
allowsReplies = true,
|
||||
distributionId = DistributionId.MY_STORY
|
||||
)
|
||||
|
||||
val expectedBody = "Hello!"
|
||||
|
||||
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
|
||||
messageSender = sender,
|
||||
storyAuthor = harness.self,
|
||||
storySentTimestamp = expectedSentTime
|
||||
) {
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
runTestWithContent(contentProto = storyContent)
|
||||
|
||||
val replyId = SignalDatabase.messages.getConversation(senderThreadId, 0, 1).use {
|
||||
it.moveToFirst()
|
||||
it.requireLong(MessageTable.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.DirectReply(storyMessageId).serialize(), replyRecord.parentStoryId!!.serialize())
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.messages.deleteAllThreads()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenContentWithAGroupStoryReplyWhenIProcessThenIInsertAReplyToTheCorrectStory() {
|
||||
val sender = Recipient.resolved(harness.others[0])
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(
|
||||
listOf(
|
||||
DecryptedMember.newBuilder()
|
||||
.setAciBytes(harness.self.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setAciBytes(sender.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
val group = SignalDatabase.groups.create(
|
||||
groupMasterKey,
|
||||
decryptedGroupState
|
||||
)
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(group!!)
|
||||
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val insertResult = MmsHelper.insert(
|
||||
message = IncomingMediaMessage(
|
||||
from = sender.id,
|
||||
sentTimeMillis = 100L,
|
||||
serverTimeMillis = 101L,
|
||||
receivedTimeMillis = 102L,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
threadId = threadForGroup
|
||||
)
|
||||
|
||||
val expectedBody = "Hello, World!"
|
||||
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
|
||||
messageSender = sender,
|
||||
storyAuthor = sender,
|
||||
storySentTimestamp = 100L
|
||||
) {
|
||||
groupV2 = TestProtos.build { groupContextV2(masterKeyBytes = groupMasterKey.serialize()).build() }
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
runTestWithContent(storyContent)
|
||||
|
||||
val replyId = SignalDatabase.messages.getStoryReplies(insertResult.get().messageId).use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
cursor.moveToFirst()
|
||||
cursor.requireLong(MessageTable.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.GroupReply(insertResult.get().messageId).serialize(), replyRecord.parentStoryId?.serialize())
|
||||
assertEquals(threadForGroup, replyRecord.threadId)
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.messages.deleteGroupStoryReplies(insertResult.get().messageId)
|
||||
SignalDatabase.messages.deleteAllThreads()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ServiceContent proto with a StoryContext, and then
|
||||
* uses `injectDataMessage` to fill in the data message object.
|
||||
*/
|
||||
private fun createServiceContentWithStoryContext(
|
||||
messageSender: Recipient,
|
||||
storyAuthor: Recipient,
|
||||
storySentTimestamp: Long,
|
||||
injectDataMessage: DataMessage.Builder.() -> Unit
|
||||
): SignalServiceContentProto {
|
||||
return createServiceContentWithDataMessage(messageSender) {
|
||||
storyContext = TestProtos.build {
|
||||
storyContext(
|
||||
sentTimestamp = storySentTimestamp,
|
||||
authorUuid = storyAuthor.requireServiceId().toString()
|
||||
).build()
|
||||
}
|
||||
injectDataMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runTestWithContent(contentProto: SignalServiceContentProto) {
|
||||
val content = SignalServiceContent.createFromProto(contentProto)
|
||||
val testSubject = createNormalContentTestSubject()
|
||||
testSubject.doProcess(content = content!!)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
|
||||
@Suppress("ClassName")
|
||||
class MessageContentProcessor__handleTextMessageTest : MessageContentProcessorTest() {
|
||||
@Test
|
||||
fun givenContentWithATextMessageWhenIProcessThenIInsertTheTextMessage() {
|
||||
val testSubject: MessageContentProcessor = createNormalContentTestSubject()
|
||||
val expectedBody = "Hello, World!"
|
||||
val contentProto: SignalServiceContentProto = createServiceContentWithDataMessage {
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
val content = SignalServiceContent.createFromProto(contentProto)
|
||||
|
||||
// WHEN
|
||||
testSubject.doProcess(content = content!!)
|
||||
|
||||
// THEN
|
||||
val record = SignalDatabase.messages.getMessageRecord(1)
|
||||
val threadSize = SignalDatabase.messages.getMessageCountForThread(record.threadId)
|
||||
assertEquals(1, threadSize)
|
||||
|
||||
assertTrue(record.isSecure)
|
||||
assertEquals(expectedBody, record.body)
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,17 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupC
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageContentProcessorV2__recipientStatusTest {
|
||||
class MessageContentProcessor__recipientStatusTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var processorV2: MessageContentProcessorV2
|
||||
private lateinit var processor: MessageContentProcessor
|
||||
private var envelopeTimestamp: Long = 0
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
processorV2 = MessageContentProcessorV2(harness.context)
|
||||
processor = MessageContentProcessor(harness.context)
|
||||
envelopeTimestamp = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class MessageContentProcessorV2__recipientStatusTest {
|
||||
timestamp = envelopeTimestamp
|
||||
}
|
||||
|
||||
processorV2.process(
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
@@ -61,7 +61,7 @@ class MessageContentProcessorV2__recipientStatusTest {
|
||||
val firstMessageId = firstSyncMessages[0].id
|
||||
val firstReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
|
||||
|
||||
processorV2.process(
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
@@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -59,14 +59,14 @@ class MessageProcessingPerformanceTest {
|
||||
mockkStatic(UnidentifiedAccessUtil::class)
|
||||
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkObject(MessageContentProcessorV2)
|
||||
every { MessageContentProcessorV2.create(harness.application) } returns TimingMessageContentProcessorV2(harness.application)
|
||||
mockkObject(MessageContentProcessor)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(UnidentifiedAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessorV2::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -107,7 +107,7 @@ class MessageProcessingPerformanceTest {
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(lastTimestamp))
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
.awaitFor(1.minutes)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
@@ -126,7 +126,7 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessorV2.TAG }.drop(2)
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
|
||||
val iterator = takeLast.iterator()
|
||||
var processCount = 0L
|
||||
var processDuration = 0L
|
||||
@@ -142,7 +142,7 @@ class MessageProcessingPerformanceTest {
|
||||
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
|
||||
|
||||
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessorV2.endTag(lastTimestamp) }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
|
||||
|
||||
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
|
||||
val messagePerSecond = messageCount.toFloat() / duration
|
||||
@@ -157,7 +157,7 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
@@ -179,32 +179,19 @@ class MessageProcessingPerformanceTest {
|
||||
}
|
||||
|
||||
private fun webSocketTombstone(): ByteString {
|
||||
return WebSocketMessage
|
||||
.newBuilder()
|
||||
.setRequest(
|
||||
WebSocketRequestMessage.newBuilder()
|
||||
.setVerb("PUT")
|
||||
.setPath("/api/v1/queue/empty")
|
||||
)
|
||||
.build()
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): ByteString {
|
||||
return WebSocketMessage
|
||||
.newBuilder()
|
||||
.setType(WebSocketMessage.Type.REQUEST)
|
||||
.setRequest(
|
||||
WebSocketRequestMessage.newBuilder()
|
||||
.setVerb("PUT")
|
||||
.setPath("/api/v1/message")
|
||||
.setId(Random(System.currentTimeMillis()).nextLong())
|
||||
.addHeaders("X-Signal-Timestamp: ${this.timestamp}")
|
||||
.setBody(this.toByteString())
|
||||
return WebSocketMessage(
|
||||
type = WebSocketMessage.Type.REQUEST,
|
||||
request = WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random(System.currentTimeMillis()).nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.toByteArray().toByteString()
|
||||
)
|
||||
.build()
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
).encodeByteString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
|
||||
class TimingMessageContentProcessorV2(context: Context) : MessageContentProcessorV2(context) {
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessorV2::class.java)
|
||||
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
@@ -38,15 +38,21 @@ class ContactRecordProcessorTest {
|
||||
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setAci(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(100)
|
||||
}
|
||||
val remote1 = buildRecord(
|
||||
STORAGE_ID_B,
|
||||
ContactRecord(
|
||||
aci = ACI_A.toString(),
|
||||
unregisteredAtTimestamp = 100
|
||||
)
|
||||
)
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setPni(PNI_A.toString())
|
||||
setE164(E164_A)
|
||||
}
|
||||
val remote2 = buildRecord(
|
||||
STORAGE_ID_C,
|
||||
ContactRecord(
|
||||
pni = PNI_A.toString(),
|
||||
e164 = E164_A
|
||||
)
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val subject = ContactRecordProcessor()
|
||||
@@ -69,16 +75,22 @@ class ContactRecordProcessorTest {
|
||||
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setAci(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(0)
|
||||
}
|
||||
val remote1 = buildRecord(
|
||||
STORAGE_ID_B,
|
||||
ContactRecord(
|
||||
aci = ACI_A.toString(),
|
||||
unregisteredAtTimestamp = 0
|
||||
)
|
||||
)
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setAci(PNI_A.toString())
|
||||
setPni(PNI_A.toString())
|
||||
setE164(E164_A)
|
||||
}
|
||||
val remote2 = buildRecord(
|
||||
STORAGE_ID_C,
|
||||
ContactRecord(
|
||||
aci = PNI_A.toString(),
|
||||
pni = PNI_A.toString(),
|
||||
e164 = E164_A
|
||||
)
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val subject = ContactRecordProcessor()
|
||||
@@ -94,8 +106,8 @@ class ContactRecordProcessorTest {
|
||||
assertEquals(byAci, byE164)
|
||||
}
|
||||
|
||||
private fun buildRecord(id: StorageId, applyParams: ContactRecord.Builder.() -> ContactRecord.Builder): SignalContactRecord {
|
||||
return SignalContactRecord(id, ContactRecord.getDefaultInstance().toBuilder().applyParams().build())
|
||||
private fun buildRecord(id: StorageId, record: ContactRecord): SignalContactRecord {
|
||||
return SignalContactRecord(id, record)
|
||||
}
|
||||
|
||||
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
|
||||
|
||||
@@ -32,10 +32,10 @@ import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.UnsupportedOperationException
|
||||
|
||||
/**
|
||||
* Welcome to Bob's Client.
|
||||
@@ -144,7 +144,6 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
|
||||
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
|
||||
|
||||
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
@@ -162,6 +161,8 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun markKyberPreKeyUsed(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
@@ -171,8 +172,9 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException()
|
||||
override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
|
||||
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import android.database.Cursor
|
||||
import android.util.Base64
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.Matchers.hasSize
|
||||
import org.hamcrest.Matchers.`is`
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.hamcrest.Matchers.notNullValue
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
@@ -53,3 +61,29 @@ fun CountDownLatch.awaitFor(duration: Duration) {
|
||||
throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms")
|
||||
}
|
||||
}
|
||||
|
||||
fun dumpTableToLogs(tag: String = "TestUtils", table: String) {
|
||||
dumpTable(table).forEach { Log.d(tag, it.toString()) }
|
||||
}
|
||||
|
||||
fun dumpTable(table: String): List<List<Pair<String, String?>>> {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select()
|
||||
.from(table)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
|
||||
val index = cursor.getColumnIndex(column)
|
||||
var data: String? = when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
|
||||
column to data
|
||||
}
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_STATE"/>
|
||||
@@ -94,6 +96,10 @@
|
||||
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<application android:name=".ApplicationContext"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -1134,9 +1140,11 @@
|
||||
|
||||
<service
|
||||
android:name=".components.voice.VoiceNotePlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
@@ -1323,7 +1331,7 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".messageprocessingalarm.MessageProcessReceiver" android:exported="false">
|
||||
<receiver android:name=".messageprocessingalarm.RoutineMessageFetchReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="org.thoughtcrime.securesms.action.PROCESS_MESSAGES" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -49,7 +48,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
@@ -61,7 +59,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
@@ -71,7 +68,7 @@ import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
||||
@@ -93,6 +90,8 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -186,7 +185,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
|
||||
.addNonBlocking(this::initializePeriodicTasks)
|
||||
.addNonBlocking(this::initializeCircumvention)
|
||||
.addNonBlocking(this::initializePendingMessages)
|
||||
.addNonBlocking(this::initializeCleanup)
|
||||
.addNonBlocking(this::initializeGlideCodecs)
|
||||
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
||||
@@ -406,7 +404,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
DirectoryRefreshListener.schedule(this);
|
||||
LocalBackupListener.schedule(this);
|
||||
RotateSenderCertificateListener.schedule(this);
|
||||
MessageProcessReceiver.startOrUpdateAlarm(this);
|
||||
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
|
||||
|
||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
||||
UpdateApkRefreshListener.schedule(this);
|
||||
@@ -449,18 +447,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
private void initializePendingMessages() {
|
||||
if (TextSecurePreferences.getNeedsMessagePull(this)) {
|
||||
Log.i(TAG, "Scheduling a message fetch.");
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
FcmJobService.schedule(this);
|
||||
} else {
|
||||
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
|
||||
}
|
||||
TextSecurePreferences.setNeedsMessagePull(this, false);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void initializeBlobProvider() {
|
||||
BlobProvider.getInstance().initialize(this);
|
||||
|
||||
@@ -57,6 +57,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
|
||||
void setEventListener(@Nullable EventListener listener);
|
||||
|
||||
default void setParentScrolling(boolean isParentScrolling) {
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
default void updateTimestamps() {
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -15,14 +16,16 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
|
||||
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics;
|
||||
import org.thoughtcrime.securesms.notifications.SlowNotificationsViewModel;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -42,6 +45,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
private SlowNotificationsViewModel slowNotificationsViewModel;
|
||||
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
private boolean onFirstRender = false;
|
||||
|
||||
@@ -76,6 +82,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
});
|
||||
|
||||
lifecycleDisposable.bindTo(this);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this, true);
|
||||
|
||||
@@ -91,6 +98,28 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
|
||||
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||
updateTabVisibility();
|
||||
|
||||
slowNotificationsViewModel = new ViewModelProvider(this).get(SlowNotificationsViewModel.class);
|
||||
|
||||
lifecycleDisposable.add(
|
||||
slowNotificationsViewModel
|
||||
.getSlowNotificationState()
|
||||
.subscribe(this::presentSlowNotificationState)
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private void presentSlowNotificationState(SlowNotificationsViewModel.State slowNotificationState) {
|
||||
switch (slowNotificationState) {
|
||||
case NONE:
|
||||
break;
|
||||
case PROMPT_BATTERY_SAVER_DIALOG:
|
||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -139,9 +168,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
|
||||
updateTabVisibility();
|
||||
|
||||
if (SlowNotificationHeuristics.shouldPromptUserForLogs()) {
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
|
||||
}
|
||||
slowNotificationsViewModel.checkSlowNotificationHeuristics();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
@@ -80,15 +79,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
protected void onPreCreate() {}
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
if (networkAccess.isCensored()) {
|
||||
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
@@ -60,7 +61,7 @@ public class DatabaseAttachment extends Attachment {
|
||||
@Override
|
||||
@Nullable
|
||||
public Uri getUri() {
|
||||
if (hasData) {
|
||||
if (hasData || (FeatureFlags.instantVideoPlayback() && getIncrementalDigest() != null)) {
|
||||
return PartAuthority.getAttachmentDataUri(attachmentId);
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.permissions.PermissionCompat
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -238,7 +239,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
@Suppress("DEPRECATION")
|
||||
private fun openGallery() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
.request(*PermissionCompat.forImages())
|
||||
.ifNecessary()
|
||||
.onAllGranted {
|
||||
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
|
||||
|
||||
@@ -95,8 +95,10 @@ class BadgeImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun clearDrawable() {
|
||||
setImageDrawable(null)
|
||||
isClickable = false
|
||||
if (drawable != null) {
|
||||
setImageDrawable(null)
|
||||
isClickable = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGlideRequests(): GlideRequests? {
|
||||
|
||||
@@ -59,73 +59,93 @@ class BadgeSpriteTransformation(
|
||||
return outBitmap
|
||||
}
|
||||
|
||||
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
|
||||
enum class Size(val code: String) {
|
||||
SMALL(
|
||||
"small",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
|
||||
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
|
||||
Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
|
||||
)
|
||||
),
|
||||
"small"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
|
||||
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
|
||||
Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
|
||||
)
|
||||
}
|
||||
},
|
||||
MEDIUM(
|
||||
"medium",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
|
||||
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
|
||||
Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
|
||||
)
|
||||
),
|
||||
"medium"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
|
||||
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
|
||||
Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
|
||||
)
|
||||
}
|
||||
},
|
||||
LARGE(
|
||||
"large",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
|
||||
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
|
||||
Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
|
||||
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
|
||||
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
|
||||
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
|
||||
)
|
||||
),
|
||||
"large"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
|
||||
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
|
||||
Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
|
||||
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
|
||||
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
|
||||
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
|
||||
)
|
||||
}
|
||||
},
|
||||
BADGE_64(
|
||||
"badge_64",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
|
||||
Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
|
||||
Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
|
||||
)
|
||||
),
|
||||
"badge_64"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
|
||||
Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
|
||||
Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
|
||||
)
|
||||
}
|
||||
},
|
||||
BADGE_112(
|
||||
"badge_112",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
|
||||
Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
|
||||
Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
|
||||
Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
|
||||
Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
|
||||
Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
|
||||
)
|
||||
),
|
||||
"badge_112"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
|
||||
Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
|
||||
Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
|
||||
Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
|
||||
Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
|
||||
Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
|
||||
)
|
||||
}
|
||||
},
|
||||
XLARGE(
|
||||
"xlarge",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
|
||||
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
|
||||
Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
|
||||
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
|
||||
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
|
||||
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
|
||||
)
|
||||
);
|
||||
"xlarge"
|
||||
) {
|
||||
override val frameMap: Map<Density, FrameSet> by lazy {
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
|
||||
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
|
||||
Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
|
||||
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
|
||||
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
|
||||
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
abstract val frameMap: Map<Density, FrameSet>
|
||||
|
||||
companion object {
|
||||
fun fromInteger(integer: Int): Size {
|
||||
|
||||
@@ -54,7 +54,7 @@ object CallLinks {
|
||||
|
||||
@JvmStatic
|
||||
fun isCallLink(url: String): Boolean {
|
||||
if (FeatureFlags.adHocCalling()) {
|
||||
if (!FeatureFlags.adHocCalling()) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -46,12 +46,10 @@ import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
@@ -212,15 +210,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
MultiselectForwardFragment.showFullScreen(
|
||||
childFragmentManager,
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = listOf(
|
||||
MultiShareArgs.Builder()
|
||||
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.build()
|
||||
)
|
||||
startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
requireContext(),
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.graphics.withClip
|
||||
@@ -18,15 +17,15 @@ class ClippedCardView @JvmOverloads constructor(
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialCardView(context, attrs) {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val boundsF = RectF()
|
||||
private val path = Path()
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.getClipBounds(bounds)
|
||||
boundsF.set(bounds)
|
||||
path.reset()
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
boundsF.set(0f, 0f, w.toFloat(), h.toFloat())
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
path.reset()
|
||||
path.addRoundRect(boundsF, radius, radius, Path.Direction.CW)
|
||||
canvas.withClip(path) {
|
||||
super.draw(canvas)
|
||||
|
||||
@@ -326,7 +326,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
timestamp = messageRecord.getDateSent();
|
||||
}
|
||||
}
|
||||
String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, timestamp);
|
||||
String date = DateUtils.getDatelessRelativeTimeSpanString(getContext(), locale, timestamp);
|
||||
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {
|
||||
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ class ConversationItemThumbnail @JvmOverloads constructor(
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
fun setProgressWheelClickListener(listener: SlideClickListener?) {
|
||||
state = state.copy(
|
||||
thumbnailViewState = state.thumbnailViewState.copy(progressWheelClickListener = listener)
|
||||
)
|
||||
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
private fun setThumbnailBounds(bounds: IntArray) {
|
||||
val (minWidth, maxWidth, minHeight, maxHeight) = bounds
|
||||
state = state.copy(
|
||||
|
||||
@@ -31,6 +31,8 @@ data class ConversationItemThumbnailState(
|
||||
@IgnoredOnParcel
|
||||
private val downloadClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val progressWheelClickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val longClickListener: OnLongClickListener? = null,
|
||||
private val visibility: Int = View.GONE,
|
||||
private val minWidth: Int = -1,
|
||||
@@ -55,6 +57,7 @@ data class ConversationItemThumbnailState(
|
||||
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
thumbnailView.get().setThumbnailClickListener(clickListener)
|
||||
thumbnailView.get().setDownloadClickListener(downloadClickListener)
|
||||
thumbnailView.get().setProgressWheelClickListener(progressWheelClickListener)
|
||||
thumbnailView.get().setOnLongClickListener(longClickListener)
|
||||
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.view.animation.LinearInterpolator;
|
||||
import android.view.animation.RotateAnimation;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
@@ -133,6 +134,7 @@ public class DeliveryStatusView extends AppCompatImageView {
|
||||
state = State.NONE;
|
||||
clearAnimation();
|
||||
setVisibility(View.GONE);
|
||||
updateContentDescription();
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
@@ -145,6 +147,7 @@ public class DeliveryStatusView extends AppCompatImageView {
|
||||
ViewUtil.setPaddingStart(this, 0);
|
||||
ViewUtil.setPaddingEnd(this, horizontalPadding);
|
||||
setImageResource(R.drawable.ic_delivery_status_sending);
|
||||
updateContentDescription();
|
||||
}
|
||||
|
||||
public void setSent() {
|
||||
@@ -154,6 +157,7 @@ public class DeliveryStatusView extends AppCompatImageView {
|
||||
ViewUtil.setPaddingEnd(this, 0);
|
||||
clearAnimation();
|
||||
setImageResource(R.drawable.ic_delivery_status_sent);
|
||||
updateContentDescription();
|
||||
}
|
||||
|
||||
public void setDelivered() {
|
||||
@@ -163,6 +167,7 @@ public class DeliveryStatusView extends AppCompatImageView {
|
||||
ViewUtil.setPaddingEnd(this, 0);
|
||||
clearAnimation();
|
||||
setImageResource(R.drawable.ic_delivery_status_delivered);
|
||||
updateContentDescription();
|
||||
}
|
||||
|
||||
public void setRead() {
|
||||
@@ -172,23 +177,36 @@ public class DeliveryStatusView extends AppCompatImageView {
|
||||
ViewUtil.setPaddingEnd(this, 0);
|
||||
clearAnimation();
|
||||
setImageResource(R.drawable.ic_delivery_status_read);
|
||||
updateContentDescription();
|
||||
}
|
||||
|
||||
public void setTint(int color) {
|
||||
setColorFilter(color);
|
||||
}
|
||||
|
||||
private void updateContentDescription() {
|
||||
if (state.contentDescription == -1) {
|
||||
setContentDescription(null);
|
||||
} else {
|
||||
setContentDescription(getContext().getString(state.contentDescription));
|
||||
}
|
||||
}
|
||||
|
||||
private enum State {
|
||||
NONE(0),
|
||||
PENDING(1),
|
||||
SENT(2),
|
||||
DELIVERED(3),
|
||||
READ(4);
|
||||
NONE(0, -1),
|
||||
PENDING(1, R.string.message_details_recipient_header__pending_send),
|
||||
SENT(2, R.string.message_details_header_sent),
|
||||
DELIVERED(3, R.string.conversation_item_sent__delivered_description),
|
||||
READ(4, R.string.conversation_item_sent__message_read);
|
||||
|
||||
final int code;
|
||||
|
||||
State(int code) {
|
||||
this.code = code;
|
||||
@StringRes
|
||||
final int contentDescription;
|
||||
|
||||
State(int code, @StringRes int contentDescription) {
|
||||
this.code = code;
|
||||
this.contentDescription = contentDescription;
|
||||
}
|
||||
|
||||
static State fromCode(int code) {
|
||||
|
||||
@@ -25,6 +25,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
private var inputId: Int? = null
|
||||
private var input: Fragment? = null
|
||||
private var wasKeyboardVisibleBeforeToggle: Boolean = false
|
||||
|
||||
val isInputShowing: Boolean
|
||||
get() = input != null
|
||||
@@ -38,11 +39,12 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun hideAll(imeTarget: EditText) {
|
||||
wasKeyboardVisibleBeforeToggle = false
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
}
|
||||
|
||||
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = false) {
|
||||
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
|
||||
if (fragmentCreator.id == inputId) {
|
||||
if (showSoftKeyOnHide) {
|
||||
showSoftkey(imeTarget)
|
||||
@@ -50,6 +52,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
}
|
||||
} else {
|
||||
wasKeyboardVisibleBeforeToggle = isKeyboardShowing
|
||||
hideInput(resetKeyboardGuideline = false)
|
||||
showInput(fragmentCreator, imeTarget)
|
||||
}
|
||||
@@ -57,6 +60,16 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
fun hideInput() {
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
wasKeyboardVisibleBeforeToggle = false
|
||||
}
|
||||
|
||||
fun hideKeyboard(imeTarget: EditText, keepHeightOverride: Boolean = false) {
|
||||
if (isKeyboardShowing) {
|
||||
if (keepHeightOverride) {
|
||||
overrideKeyboardGuidelineWithPreviousHeight()
|
||||
}
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import android.view.animation.Interpolator;
|
||||
import android.view.animation.TranslateAnimation;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -141,7 +140,7 @@ public class InputPanel extends ConstraintLayout
|
||||
public void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
View quoteDismiss = findViewById(R.id.quote_dismiss);
|
||||
View quoteDismiss = findViewById(R.id.quote_dismiss_stub);
|
||||
|
||||
this.composeContainer = findViewById(R.id.compose_bubble);
|
||||
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class LoggingAdapterDataObserver(
|
||||
private val tag: String
|
||||
) : AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
Log.d(tag, "onChanged() called")
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeChanged() called with: positionStart = $positionStart, itemCount = $itemCount")
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
|
||||
Log.d(tag, "onItemRangeChanged() called with: positionStart = $positionStart, itemCount = $itemCount, payload = $payload")
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeInserted() called with: positionStart = $positionStart, itemCount = $itemCount")
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeRemoved() called with: positionStart = $positionStart, itemCount = $itemCount")
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeMoved() called with: fromPosition = $fromPosition, toPosition = $toPosition, itemCount = $itemCount")
|
||||
}
|
||||
|
||||
override fun onStateRestorationPolicyChanged() {
|
||||
Log.d(tag, "onStateRestorationPolicyChanged() called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat
|
||||
|
||||
@RequiresApi(23)
|
||||
class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||
PromptBatterySaverDialogFragment().apply {
|
||||
arguments = bundleOf()
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
SignalStore.uiHints().lastBatterySaverPrompt = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private val binding by ViewBinderDelegate(PromptBatterySaverBottomSheetBinding::bind)
|
||||
|
||||
private lateinit var viewModel: PromptLogsViewModel
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.prompt_battery_saver_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
viewModel = ViewModelProvider(this)[PromptLogsViewModel::class.java]
|
||||
binding.continueButton.setOnClickListener {
|
||||
PowerManagerCompat.requestIgnoreBatteryOptimizations(requireContext())
|
||||
}
|
||||
binding.dismissButton.setOnClickListener {
|
||||
SignalStore.uiHints().markDismissedBatterySaverPrompt()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,16 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.google.android.material.imageview.ShapeableImageView;
|
||||
@@ -45,11 +43,12 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
public class QuoteView extends ConstraintLayout implements RecipientForeverObserver {
|
||||
|
||||
private static final String TAG = Log.tag(QuoteView.class);
|
||||
|
||||
@@ -79,17 +78,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private View background;
|
||||
private ViewGroup mainView;
|
||||
private ViewGroup footerView;
|
||||
private TextView authorView;
|
||||
private EmojiTextView bodyView;
|
||||
private View quoteBarView;
|
||||
private ShapeableImageView thumbnailView;
|
||||
private View attachmentVideoOverlayView;
|
||||
private ViewGroup attachmentContainerView;
|
||||
private TextView attachmentNameView;
|
||||
private ImageView dismissView;
|
||||
private Stub<View> attachmentVideoOVerlayStub;
|
||||
private Stub<TextView> attachmentNameViewStub;
|
||||
private Stub<ImageView> dismissStub;
|
||||
private EmojiImageView missingStoryReaction;
|
||||
private EmojiImageView storyReactionEmoji;
|
||||
|
||||
@@ -97,7 +92,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
private LiveRecipient author;
|
||||
private CharSequence body;
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private Stub<TextView> missingLinkTextStub;
|
||||
private SlideDeck attachments;
|
||||
private MessageType messageType;
|
||||
private int largeCornerRadius;
|
||||
@@ -124,32 +119,27 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
initialize(attrs);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize(attrs);
|
||||
}
|
||||
|
||||
private void initialize(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.quote_view, this);
|
||||
inflate(getContext(), R.layout.v2_quote_view, this);
|
||||
|
||||
this.background = findViewById(R.id.quote_background);
|
||||
this.mainView = findViewById(R.id.quote_main);
|
||||
this.footerView = findViewById(R.id.quote_missing_footer);
|
||||
this.authorView = findViewById(R.id.quote_author);
|
||||
this.bodyView = findViewById(R.id.quote_text);
|
||||
this.quoteBarView = findViewById(R.id.quote_bar);
|
||||
this.thumbnailView = findViewById(R.id.quote_thumbnail);
|
||||
this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay);
|
||||
this.attachmentContainerView = findViewById(R.id.quote_attachment_container);
|
||||
this.attachmentNameView = findViewById(R.id.quote_attachment_name);
|
||||
this.dismissView = findViewById(R.id.quote_dismiss);
|
||||
this.mediaDescriptionText = findViewById(R.id.media_type);
|
||||
this.missingLinkText = findViewById(R.id.quote_missing_text);
|
||||
this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
|
||||
this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
|
||||
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
|
||||
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
|
||||
this.authorView = findViewById(R.id.quote_author);
|
||||
this.bodyView = findViewById(R.id.quote_text);
|
||||
this.quoteBarView = findViewById(R.id.quote_bar);
|
||||
this.thumbnailView = findViewById(R.id.quote_thumbnail);
|
||||
this.attachmentVideoOVerlayStub = new Stub<>(findViewById(R.id.quote_video_overlay_stub));
|
||||
this.attachmentNameViewStub = new Stub<>(findViewById(R.id.quote_attachment_name_stub));
|
||||
this.dismissStub = new Stub<>(findViewById(R.id.quote_dismiss_stub));
|
||||
this.mediaDescriptionText = findViewById(R.id.media_type);
|
||||
this.missingLinkTextStub = new Stub<>(findViewById(R.id.quote_missing_text_stub));
|
||||
this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
|
||||
this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
|
||||
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
|
||||
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
|
||||
|
||||
cornerMask = new CornerMask(this);
|
||||
|
||||
@@ -159,12 +149,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
messageType = MessageType.fromCode(typedArray.getInt(R.styleable.QuoteView_message_type, 0));
|
||||
typedArray.recycle();
|
||||
|
||||
dismissView.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
|
||||
dismissStub.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
setMessageType(messageType);
|
||||
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -199,7 +187,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
params.width = thumbWidth;
|
||||
|
||||
thumbnailView.setLayoutParams(params);
|
||||
dismissView.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
|
||||
dismissStub.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
|
||||
if (dismissStub.resolved()) {
|
||||
dismissStub.get().setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
}
|
||||
|
||||
public void setQuote(GlideRequests glideRequests,
|
||||
@@ -255,11 +246,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
setQuoteAuthor(recipient);
|
||||
}
|
||||
|
||||
public @NonNull Projection getProjection(@NonNull ViewGroup parent) {
|
||||
return Projection.relativeToParent(parent, this, getCorners());
|
||||
}
|
||||
|
||||
public @NonNull Projection.Corners getCorners() {
|
||||
return new Projection.Corners(cornerMask.getRadii());
|
||||
}
|
||||
@@ -365,13 +351,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
|
||||
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
|
||||
|
||||
mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
|
||||
// TODO [alex] -- do we need this? mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
|
||||
thumbnailView.setPadding(0, 0, 0, 0);
|
||||
|
||||
StoryTextPostModel model = isStoryReply() ? getStoryTextPost(body) : null;
|
||||
if (model != null) {
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
attachmentVideoOVerlayStub.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
glideRequests.load(model)
|
||||
.centerCrop()
|
||||
@@ -388,8 +374,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
thumbnailView.setShapeAppearanceModel(buildShapeAppearanceForLayoutDirection());
|
||||
}
|
||||
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
attachmentVideoOVerlayStub.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
glideRequests.load(R.drawable.ic_gift_thumbnail)
|
||||
.centerCrop()
|
||||
@@ -403,17 +389,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
|
||||
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
|
||||
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentVideoOVerlayStub.setVisibility(GONE);
|
||||
|
||||
if (viewOnceSlide != null) {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
dismissView.setBackgroundResource(R.drawable.dismiss_background);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
|
||||
if (dismissStub.resolved()) {
|
||||
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
|
||||
}
|
||||
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
|
||||
attachmentVideoOverlayView.setVisibility(VISIBLE);
|
||||
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
|
||||
}
|
||||
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
|
||||
.centerCrop()
|
||||
@@ -422,17 +411,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
.into(thumbnailView);
|
||||
} else if (documentSlide != null){
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(VISIBLE);
|
||||
attachmentNameView.setText(documentSlide.getFileName().orElse(""));
|
||||
attachmentNameViewStub.setVisibility(VISIBLE);
|
||||
attachmentNameViewStub.get().setText(documentSlide.getFileName().orElse(""));
|
||||
} else {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
dismissView.setBackgroundDrawable(null);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
|
||||
if (dismissStub.resolved()) {
|
||||
dismissStub.get().setBackground(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setQuoteMissingFooter(boolean missing) {
|
||||
footerView.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
|
||||
missingLinkTextStub.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
private @Nullable StoryTextPostModel getStoryTextPost(@Nullable CharSequence body) {
|
||||
@@ -501,12 +493,18 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
QuoteViewColorTheme quoteViewColorTheme = QuoteViewColorTheme.resolveTheme(isOutgoing, isPreview, isWallpaperEnabled);
|
||||
|
||||
quoteBarView.setBackgroundColor(quoteViewColorTheme.getBarColor(getContext()));
|
||||
background.setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
|
||||
setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
|
||||
authorView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
bodyView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
attachmentNameView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
|
||||
if (attachmentNameViewStub.resolved()) {
|
||||
attachmentNameViewStub.get().setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
}
|
||||
mediaDescriptionText.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
missingLinkText.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
footerView.setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
|
||||
|
||||
if (missingLinkTextStub.resolved()) {
|
||||
missingLinkTextStub.get().setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
|
||||
missingLinkTextStub.get().setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ public class ThreadPhotoRailView extends FrameLayout {
|
||||
public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
|
||||
ThumbnailView imageView = viewHolder.imageView;
|
||||
MediaTable.MediaRecord mediaRecord = MediaTable.MediaRecord.from(cursor);
|
||||
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
|
||||
Slide slide = MediaUtil.getSlideForAttachment(mediaRecord.getAttachment());
|
||||
|
||||
if (slide != null) {
|
||||
imageView.setImageResource(glideRequests, slide, false, false);
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
@@ -79,11 +80,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
private final CornerMask cornerMask;
|
||||
|
||||
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
|
||||
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
|
||||
private Stub<TransferControlView> transferControlViewStub;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private Slide slide = null;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private SlideClickListener progressWheelClickListener = null;
|
||||
private Slide slide = null;
|
||||
|
||||
|
||||
public ThumbnailView(Context context) {
|
||||
@@ -366,6 +368,11 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
transferControlsState = transferControlsState.withSlide(slide)
|
||||
.withDownloadClickListener(new DownloadClickDispatcher());
|
||||
|
||||
if (FeatureFlags.instantVideoPlayback()) {
|
||||
transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher());
|
||||
}
|
||||
|
||||
transferControlsState.applyState(transferControlViewStub);
|
||||
} else {
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
@@ -518,6 +525,10 @@ public class ThumbnailView extends FrameLayout {
|
||||
this.downloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setProgressWheelClickListener(SlideClickListener listener) {
|
||||
this.progressWheelClickListener = listener;
|
||||
}
|
||||
|
||||
public void clear(GlideRequests glideRequests) {
|
||||
glideRequests.clear(image);
|
||||
image.setImageDrawable(null);
|
||||
@@ -659,6 +670,18 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressWheelClickDispatcher implements View.OnClickListener {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.i(TAG, "onClick() for progress wheel");
|
||||
if (progressWheelClickListener != null && slide != null) {
|
||||
progressWheelClickListener.onClick(view, slide);
|
||||
} else {
|
||||
Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class BlurHashClearListener implements ListenableFuture.Listener<Boolean> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
|
||||
@@ -12,6 +12,7 @@ data class ThumbnailViewTransferControlsState(
|
||||
val isClickable: Boolean = true,
|
||||
val slide: Slide? = null,
|
||||
val downloadClickedListener: OnClickListener? = null,
|
||||
val progressWheelClickedListener: OnClickListener? = null,
|
||||
val showDownloadText: Boolean = true
|
||||
) {
|
||||
|
||||
@@ -19,6 +20,7 @@ data class ThumbnailViewTransferControlsState(
|
||||
fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable)
|
||||
fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide)
|
||||
fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener)
|
||||
fun withProgressWheelClickListener(progressWheelClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(progressWheelClickedListener = progressWheelClickedListener)
|
||||
fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText)
|
||||
|
||||
fun applyState(transferControlView: Stub<TransferControlView>) {
|
||||
@@ -29,6 +31,7 @@ data class ThumbnailViewTransferControlsState(
|
||||
transferControlView.get().setSlide(slide)
|
||||
}
|
||||
transferControlView.get().setDownloadClickListener(downloadClickedListener)
|
||||
transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener)
|
||||
transferControlView.get().setShowDownloadText(showDownloadText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class TransferControlView extends FrameLayout {
|
||||
|
||||
private static final String TAG = "TransferControlView";
|
||||
private static final int UPLOAD_TASK_WEIGHT = 1;
|
||||
|
||||
/**
|
||||
@@ -125,7 +127,11 @@ public final class TransferControlView extends FrameLayout {
|
||||
break;
|
||||
case AttachmentTable.TRANSFER_PROGRESS_PENDING:
|
||||
case AttachmentTable.TRANSFER_PROGRESS_FAILED:
|
||||
downloadDetailsText.setText(getDownloadText(this.slides));
|
||||
String downloadText = getDownloadText(this.slides);
|
||||
if (!Objects.equals(downloadText, downloadDetailsText.getText().toString())) {
|
||||
downloadDetailsText.setText(getDownloadText(this.slides));
|
||||
}
|
||||
|
||||
display(downloadDetails);
|
||||
break;
|
||||
default:
|
||||
@@ -152,6 +158,10 @@ public final class TransferControlView extends FrameLayout {
|
||||
downloadDetails.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void setProgressWheelClickListener(final @Nullable OnClickListener listener) {
|
||||
progressWheel.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
clearAnimation();
|
||||
setVisibility(GONE);
|
||||
@@ -247,13 +257,14 @@ public final class TransferControlView extends FrameLayout {
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventAsync(final PartProgressEvent event) {
|
||||
if (networkProgress.containsKey(event.attachment)) {
|
||||
final Attachment attachment = event.attachment;
|
||||
if (networkProgress.containsKey(attachment)) {
|
||||
float proportionCompleted = ((float) event.progress) / event.total;
|
||||
|
||||
if (event.type == PartProgressEvent.Type.COMPRESSION) {
|
||||
compresssionProgress.put(event.attachment, proportionCompleted);
|
||||
compresssionProgress.put(attachment, proportionCompleted);
|
||||
} else {
|
||||
networkProgress.put(event.attachment, proportionCompleted);
|
||||
networkProgress.put(attachment, proportionCompleted);
|
||||
}
|
||||
|
||||
progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress));
|
||||
|
||||
@@ -26,7 +26,7 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun isEligible(): Boolean {
|
||||
return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync
|
||||
return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchL
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
segments.forEachIndexed { index, segment ->
|
||||
|
||||
@@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
},
|
||||
onQrButtonClicked = {
|
||||
if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) {
|
||||
if (SignalStore.account().username != null) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.help
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
@@ -33,40 +32,45 @@ class LicenseFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val textState: State<String> = Single.fromCallable {
|
||||
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
|
||||
}
|
||||
val textState: State<List<String>> = Single
|
||||
.fromCallable {
|
||||
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use {
|
||||
it.readText().split("\n")
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeAsState(initial = "")
|
||||
.subscribeAsState(initial = emptyList())
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.HelpSettingsFragment__licenses),
|
||||
onNavigationClick = findNavController()::popBackStack,
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) {
|
||||
LicenseScreen(licenseText = textState.value, modifier = Modifier.padding(it))
|
||||
LicenseScreen(licenseTextLines = textState.value, modifier = Modifier.padding(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LicenseScreen(licenseText: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = licenseText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
fun LicenseScreen(licenseTextLines: List<String>, modifier: Modifier = Modifier) {
|
||||
Surface(modifier = modifier) {
|
||||
LazyColumn(modifier = Modifier.padding(horizontal = 4.dp)) {
|
||||
licenseTextLines.forEach { line ->
|
||||
item {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LicenseFragmentPreview() {
|
||||
LicenseScreen("Lorem ipsum")
|
||||
LicenseScreen(listOf("Lorem ipsum", "Delor"))
|
||||
}
|
||||
|
||||
@@ -617,10 +617,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Use V2 ConversationItem"),
|
||||
isChecked = state.useConversationItemV2,
|
||||
title = DSLSettingsText.from("Use V2 ConversationItem for Media"),
|
||||
isChecked = state.useConversationItemV2ForMedia,
|
||||
onClick = {
|
||||
viewModel.setUseConversationItemV2(!state.useConversationItemV2)
|
||||
viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,5 +22,5 @@ data class InternalSettingsState(
|
||||
val disableStorageService: Boolean,
|
||||
val canClearOnboardingState: Boolean,
|
||||
val pnpInitialized: Boolean,
|
||||
val useConversationItemV2: Boolean
|
||||
val useConversationItemV2ForMedia: Boolean
|
||||
)
|
||||
|
||||
@@ -104,8 +104,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setUseConversationItemV2(enabled: Boolean) {
|
||||
SignalStore.internalValues().setUseConversationItemV2(enabled)
|
||||
fun setUseConversationItemV2Media(enabled: Boolean) {
|
||||
SignalStore.internalValues().setUseConversationItemV2Media(enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
|
||||
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
|
||||
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
|
||||
useConversationItemV2 = SignalStore.internalValues().useConversationItemV2()
|
||||
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media()
|
||||
)
|
||||
|
||||
fun onClearOnboardingState() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.preference.PreferenceManager
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
@@ -30,8 +31,11 @@ import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.RadioListPreference
|
||||
import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.Banner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.RingtoneUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
@@ -61,6 +65,11 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
|
||||
private lateinit var viewModel: NotificationsSettingsViewModel
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == MESSAGE_SOUND_SELECT && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
|
||||
@@ -77,6 +86,8 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
|
||||
)
|
||||
|
||||
Banner.register(adapter)
|
||||
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val factory = NotificationsSettingsViewModel.Factory(sharedPreferences)
|
||||
|
||||
@@ -89,10 +100,23 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
|
||||
private fun getConfiguration(state: NotificationsSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
if (!state.messageNotificationsState.canEnableNotifications) {
|
||||
customPref(
|
||||
Banner.Model(
|
||||
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
|
||||
actionId = R.string.NotificationSettingsFragment__turn_on,
|
||||
onClick = {
|
||||
TurnOnNotificationsBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__messages)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__notifications),
|
||||
isEnabled = state.messageNotificationsState.canEnableNotifications,
|
||||
isChecked = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setMessageNotificationsEnabled(!state.messageNotificationsState.notificationsEnabled)
|
||||
@@ -184,6 +208,16 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
}
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23 && state.messageNotificationsState.troubleshootNotifications) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__troubleshoot),
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
PromptBatterySaverDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 30) {
|
||||
if (NotificationChannels.supported()) {
|
||||
clickPref(
|
||||
@@ -212,6 +246,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__notifications),
|
||||
isEnabled = state.callNotificationsState.canEnableNotifications,
|
||||
isChecked = state.callNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setCallNotificationsEnabled(!state.callNotificationsState.notificationsEnabled)
|
||||
|
||||
@@ -10,6 +10,7 @@ data class NotificationsSettingsState(
|
||||
|
||||
data class MessageNotificationsState(
|
||||
val notificationsEnabled: Boolean,
|
||||
val canEnableNotifications: Boolean,
|
||||
val sound: Uri,
|
||||
val vibrateEnabled: Boolean,
|
||||
val ledColor: String,
|
||||
@@ -17,11 +18,13 @@ data class MessageNotificationsState(
|
||||
val inChatSoundsEnabled: Boolean,
|
||||
val repeatAlerts: Int,
|
||||
val messagePrivacy: String,
|
||||
val priority: Int
|
||||
val priority: Int,
|
||||
val troubleshootNotifications: Boolean
|
||||
)
|
||||
|
||||
data class CallNotificationsState(
|
||||
val notificationsEnabled: Boolean,
|
||||
val canEnableNotifications: Boolean,
|
||||
val ringtone: Uri,
|
||||
val vibrateEnabled: Boolean
|
||||
)
|
||||
|
||||
@@ -2,101 +2,115 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
|
||||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class NotificationsSettingsViewModel(private val sharedPreferences: SharedPreferences) : ViewModel() {
|
||||
|
||||
private val store = Store(getState())
|
||||
|
||||
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
if (NotificationChannels.supported()) {
|
||||
SignalStore.settings().messageNotificationSound = NotificationChannels.getInstance().messageRingtone
|
||||
SignalStore.settings().isMessageVibrateEnabled = NotificationChannels.getInstance().messageVibrate
|
||||
}
|
||||
|
||||
store.update { getState(calculateSlowNotifications = true) }
|
||||
}
|
||||
|
||||
private val store = Store(getState())
|
||||
|
||||
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
|
||||
fun refresh() {
|
||||
store.update { getState(currentState = it) }
|
||||
}
|
||||
|
||||
fun setMessageNotificationsEnabled(enabled: Boolean) {
|
||||
SignalStore.settings().isMessageNotificationsEnabled = enabled
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationsSound(sound: Uri?) {
|
||||
val messageSound = sound ?: Uri.EMPTY
|
||||
SignalStore.settings().messageNotificationSound = messageSound
|
||||
NotificationChannels.getInstance().updateMessageRingtone(messageSound)
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationVibration(enabled: Boolean) {
|
||||
SignalStore.settings().isMessageVibrateEnabled = enabled
|
||||
NotificationChannels.getInstance().updateMessageVibrate(enabled)
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationLedColor(color: String) {
|
||||
SignalStore.settings().messageLedColor = color
|
||||
NotificationChannels.getInstance().updateMessagesLedColor(color)
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationLedBlink(blink: String) {
|
||||
SignalStore.settings().messageLedBlinkPattern = blink
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) {
|
||||
SignalStore.settings().isMessageNotificationsInChatSoundsEnabled = enabled
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageRepeatAlerts(repeats: Int) {
|
||||
SignalStore.settings().messageNotificationsRepeatAlerts = repeats
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationPrivacy(preference: String) {
|
||||
SignalStore.settings().messageNotificationsPrivacy = NotificationPrivacyPreference(preference)
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setMessageNotificationPriority(priority: Int) {
|
||||
sharedPreferences.edit().putString(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF, priority.toString()).apply()
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setCallNotificationsEnabled(enabled: Boolean) {
|
||||
SignalStore.settings().isCallNotificationsEnabled = enabled
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setCallRingtone(ringtone: Uri?) {
|
||||
SignalStore.settings().callRingtone = ringtone ?: Uri.EMPTY
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setCallVibrateEnabled(enabled: Boolean) {
|
||||
SignalStore.settings().isCallVibrateEnabled = enabled
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setNotifyWhenContactJoinsSignal(enabled: Boolean) {
|
||||
SignalStore.settings().isNotifyWhenContactJoinsSignal = enabled
|
||||
store.update { getState() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun getState(): NotificationsSettingsState = NotificationsSettingsState(
|
||||
/**
|
||||
* @param currentState If provided and [calculateSlowNotifications] = false, then we will copy the slow notification state from it
|
||||
* @param calculateSlowNotifications If true, calculate the true slow notification state (this is not main-thread safe). Otherwise, it will copy from
|
||||
* [currentState] or default to false.
|
||||
*/
|
||||
private fun getState(currentState: NotificationsSettingsState? = null, calculateSlowNotifications: Boolean = false): NotificationsSettingsState = NotificationsSettingsState(
|
||||
messageNotificationsState = MessageNotificationsState(
|
||||
notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled,
|
||||
notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled && canEnableNotifications(),
|
||||
canEnableNotifications = canEnableNotifications(),
|
||||
sound = SignalStore.settings().messageNotificationSound,
|
||||
vibrateEnabled = SignalStore.settings().isMessageVibrateEnabled,
|
||||
ledColor = SignalStore.settings().messageLedColor,
|
||||
@@ -104,16 +118,34 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
|
||||
inChatSoundsEnabled = SignalStore.settings().isMessageNotificationsInChatSoundsEnabled,
|
||||
repeatAlerts = SignalStore.settings().messageNotificationsRepeatAlerts,
|
||||
messagePrivacy = SignalStore.settings().messageNotificationsPrivacy.toString(),
|
||||
priority = TextSecurePreferences.getNotificationPriority(ApplicationDependencies.getApplication())
|
||||
priority = TextSecurePreferences.getNotificationPriority(ApplicationDependencies.getApplication()),
|
||||
troubleshootNotifications = if (calculateSlowNotifications) {
|
||||
SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.isHavingDelayedNotifications()
|
||||
} else if (currentState != null) {
|
||||
currentState.messageNotificationsState.troubleshootNotifications
|
||||
} else {
|
||||
false
|
||||
}
|
||||
),
|
||||
callNotificationsState = CallNotificationsState(
|
||||
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled,
|
||||
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled && canEnableNotifications(),
|
||||
canEnableNotifications = canEnableNotifications(),
|
||||
ringtone = SignalStore.settings().callRingtone,
|
||||
vibrateEnabled = SignalStore.settings().isCallVibrateEnabled
|
||||
),
|
||||
notifyWhenContactJoinsSignal = SignalStore.settings().isNotifyWhenContactJoinsSignal
|
||||
)
|
||||
|
||||
private fun canEnableNotifications(): Boolean {
|
||||
val areNotificationsDisabledBySystem = Build.VERSION.SDK_INT >= 26 && (
|
||||
!NotificationChannels.getInstance().isMessageChannelEnabled ||
|
||||
!NotificationChannels.getInstance().isMessagesChannelGroupEnabled ||
|
||||
!NotificationChannels.getInstance().areNotificationsEnabled()
|
||||
)
|
||||
|
||||
return !areNotificationsDisabledBySystem
|
||||
}
|
||||
|
||||
class Factory(private val sharedPreferences: SharedPreferences) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(NotificationsSettingsViewModel(sharedPreferences)))
|
||||
|
||||
@@ -60,7 +60,7 @@ private fun DrawScope.drawQr(
|
||||
deadzonePercent: Float,
|
||||
logo: ImageBitmap
|
||||
) {
|
||||
val deadzonePaddingPercent = 0.07f
|
||||
val deadzonePaddingPercent = 0.045f
|
||||
|
||||
// We want an even number of dots on either side of the deadzone
|
||||
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -20,16 +29,22 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.compose.getScreenshotBounds
|
||||
|
||||
@@ -37,19 +52,25 @@ import org.thoughtcrime.securesms.compose.getScreenshotBounds
|
||||
* Renders a QR code and username as a badge.
|
||||
*/
|
||||
@Composable
|
||||
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
|
||||
fun QrCodeBadge(
|
||||
data: QrCodeState,
|
||||
colorScheme: UsernameQrCodeColorScheme,
|
||||
username: String,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null,
|
||||
usernameCopyable: Boolean = false,
|
||||
onClick: (() -> Unit) = {}
|
||||
) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border")
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground")
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation")
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor")
|
||||
var badgeBounds by remember {
|
||||
mutableStateOf<Rect?>(null)
|
||||
}
|
||||
screenshotController?.bind(LocalView.current, badgeBounds)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 59.dp, vertical = 24.dp)
|
||||
.onGloballyPositioned {
|
||||
badgeBounds = it.getScreenshotBounds()
|
||||
},
|
||||
@@ -57,24 +78,32 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = elevation.dp
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(296.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = 32.dp,
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 16.dp
|
||||
end = 40.dp
|
||||
)
|
||||
.aspectRatio(1f)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White
|
||||
) {
|
||||
if (data != null) {
|
||||
if (data is QrCodeState.Present) {
|
||||
QrCode(
|
||||
data = data,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
data = data.data,
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp,
|
||||
color = Color(0xFFE9E9E9),
|
||||
shape = RoundedCornerShape(size = 12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
foregroundColor = foregroundColor,
|
||||
backgroundColor = Color.White
|
||||
)
|
||||
@@ -85,40 +114,169 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = colorScheme.borderColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
if (data is QrCodeState.Loading) {
|
||||
CircularProgressIndicator(
|
||||
color = colorScheme.borderColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
} else if (data is QrCodeState.NotSet) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_error_circle_24),
|
||||
contentDescription = stringResource(id = R.string.UsernameLinkSettings_link_not_set_label),
|
||||
colorFilter = ColorFilter.tint(colorResource(R.color.core_grey_25)),
|
||||
modifier = Modifier
|
||||
.width(28.dp)
|
||||
.height(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 32.dp
|
||||
start = 32.dp,
|
||||
end = 32.dp,
|
||||
top = 8.dp,
|
||||
bottom = 28.dp
|
||||
)
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
enabled = usernameCopyable,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (usernameCopyable) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
contentDescription = null,
|
||||
colorFilter = if (colorScheme == UsernameQrCodeColorScheme.White) {
|
||||
ColorFilter.tint(Color.Black)
|
||||
} else {
|
||||
ColorFilter.tint(Color.White)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewWithCode() {
|
||||
private fun PreviewWithCodeShort() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "LongName")
|
||||
@Composable
|
||||
private fun PreviewWithCodeLong() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "Colors", heightDp = 1500)
|
||||
@Composable
|
||||
private fun PreviewAllColorsP1() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Column {
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Blue)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.White)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Green)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Grey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "Colors", heightDp = 1500)
|
||||
@Composable
|
||||
private fun PreviewAllColorsP2() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Column {
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Pink)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Orange)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Purple)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Tan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)),
|
||||
colorScheme = colorScheme,
|
||||
username = "parker.42"
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewLoading() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
data = QrCodeState.Loading,
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
@@ -126,13 +284,14 @@ private fun PreviewWithCode() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Light Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewWithoutCode() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun PreviewNotSet() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = null,
|
||||
data = QrCodeState.NotSet,
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class QrCodeData(
|
||||
@WorkerThread
|
||||
fun forData(data: String, size: Int): QrCodeData {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString())
|
||||
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val dimens = padded.enclosingRectangle
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
sealed class QrCodeState {
|
||||
/** QR code data exists and is available. */
|
||||
data class Present(val data: QrCodeData) : QrCodeState()
|
||||
|
||||
/** QR code data does not exist. */
|
||||
object NotSet : QrCodeState()
|
||||
|
||||
/** QR code data is in an indeterminate loading state. */
|
||||
object Loading : QrCodeState()
|
||||
}
|
||||
@@ -17,7 +17,7 @@ enum class UsernameQrCodeColorScheme(
|
||||
),
|
||||
White(
|
||||
borderColor = Color(0xFFFFFFFF),
|
||||
foregroundColor = Color(0xFF464852),
|
||||
foregroundColor = Color(0xFF000000),
|
||||
key = "white"
|
||||
),
|
||||
Grey(
|
||||
|
||||
@@ -65,12 +65,14 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
||||
.padding(contentPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
QrCodeBadge(
|
||||
data = state.qrCodeData,
|
||||
colorScheme = state.selectedColorScheme,
|
||||
username = state.username
|
||||
username = state.username,
|
||||
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp)
|
||||
)
|
||||
|
||||
ColorPicker(
|
||||
@@ -160,7 +162,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerItemPreview() {
|
||||
private fun PreviewColorPickerItem() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -173,7 +175,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerPreview() {
|
||||
private fun PreviewColorPicker() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
ColorPicker(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
|
||||
data class UsernameLinkQrColorPickerState(
|
||||
val username: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val qrCodeData: QrCodeState,
|
||||
val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>,
|
||||
val selectedColorScheme: UsernameQrCodeColorScheme
|
||||
)
|
||||
|
||||
@@ -9,20 +9,22 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
|
||||
class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
|
||||
private val username: String = Recipient.self().username.get()
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkQrColorPickerState(
|
||||
username = username,
|
||||
qrCodeData = null,
|
||||
username = SignalStore.account().username!!,
|
||||
qrCodeData = QrCodeState.Loading,
|
||||
colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(),
|
||||
selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme
|
||||
)
|
||||
@@ -33,15 +35,23 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
private val disposable: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = qrData
|
||||
)
|
||||
}
|
||||
val usernameLink = SignalStore.account().usernameLink
|
||||
|
||||
if (usernameLink != null) {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = QrCodeState.Present(qrData)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = QrCodeState.NotSet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -50,6 +60,11 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
|
||||
fun onColorSelected(color: UsernameQrCodeColorScheme) {
|
||||
SignalStore.misc().usernameQrCodeColorScheme = color
|
||||
SignalExecutors.BOUNDED.run {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
selectedColorScheme = color
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
sealed class QrScanResult {
|
||||
class Success(val recipient: Recipient) : QrScanResult()
|
||||
|
||||
class NotFound(val username: String) : QrScanResult()
|
||||
class NotFound(val username: String?) : QrScanResult()
|
||||
|
||||
object InvalidData : QrScanResult()
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
|
||||
/**
|
||||
* Result of resetting the username link.
|
||||
*/
|
||||
sealed class UsernameLinkResetResult {
|
||||
/** Successfully reset the username link. */
|
||||
data class Success(val components: UsernameLinkComponents) : UsernameLinkResetResult()
|
||||
|
||||
/** Network failed when making the request. The username is still considered to be "reset". */
|
||||
object NetworkError : UsernameLinkResetResult()
|
||||
|
||||
/** We never made the request because we detected the user had no network. */
|
||||
object NetworkUnavailable : UsernameLinkResetResult()
|
||||
|
||||
/** We never made the request because we hit an unexpected error. */
|
||||
object UnexpectedError : UsernameLinkResetResult()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -55,7 +56,6 @@ import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
@@ -71,6 +71,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
val scope: CoroutineScope = rememberCoroutineScope()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
var showResetDialog: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
@@ -95,7 +96,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
onShareBadge = {
|
||||
shareQrBadge(it)
|
||||
},
|
||||
screenshotController = screenshotController
|
||||
screenshotController = screenshotController,
|
||||
onResetClicked = { showResetDialog = true },
|
||||
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +117,16 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showResetDialog) {
|
||||
ResetDialog(
|
||||
onConfirm = {
|
||||
viewModel.onUsernameLinkReset()
|
||||
showResetDialog = false
|
||||
},
|
||||
onDismiss = { showResetDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -182,20 +195,43 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title),
|
||||
body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body),
|
||||
confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppBarPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun PreviewAppBar() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
TopAppBarContent(activeTab = ActiveTab.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewAll() {
|
||||
FragmentContent()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAll() {
|
||||
FragmentContent()
|
||||
private fun PreviewResetDialog() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
ResetDialog(onConfirm = {}, onDismiss = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareQrBadge(badge: Bitmap) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
|
||||
/**
|
||||
@@ -9,10 +9,11 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
|
||||
data class UsernameLinkSettingsState(
|
||||
val activeTab: ActiveTab,
|
||||
val username: String,
|
||||
val usernameLink: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val usernameLinkState: UsernameLinkState,
|
||||
val qrCodeState: QrCodeState,
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme,
|
||||
val qrScanResult: QrScanResult? = null,
|
||||
val usernameLinkResetResult: UsernameLinkResetResult? = null,
|
||||
val indeterminateProgress: Boolean = false
|
||||
) {
|
||||
enum class ActiveTab {
|
||||
|
||||
@@ -10,46 +10,46 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import java.io.IOException
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Optional
|
||||
|
||||
class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
|
||||
|
||||
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = username.value!!,
|
||||
usernameLink = UsernameUtil.generateLink(username.value!!),
|
||||
qrCodeData = null,
|
||||
username = SignalStore.account().username!!,
|
||||
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
|
||||
qrCodeState = QrCodeState.Loading,
|
||||
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<UsernameLinkSettingsState> = _state
|
||||
|
||||
private val disposable: CompositeDisposable = CompositeDisposable()
|
||||
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
|
||||
private val usernameRepo: UsernameRepository = UsernameRepository()
|
||||
|
||||
init {
|
||||
disposable += username
|
||||
disposable += usernameLink
|
||||
.observeOn(Schedulers.io())
|
||||
.map { UsernameUtil.generateLink(it) }
|
||||
.map { link -> link.map { UsernameUtil.generateLink(it) } }
|
||||
.flatMapSingle { generateQrCodeData(it) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = qrData
|
||||
qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,37 +70,70 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun onUsernameLinkReset() {
|
||||
if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) {
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkResetResult = UsernameLinkResetResult.NetworkUnavailable
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val currentValue = _state.value
|
||||
val previousQrValue: QrCodeData? = if (currentValue.qrCodeState is QrCodeState.Present) {
|
||||
currentValue.qrCodeState.data
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkState = UsernameLinkState.Resetting,
|
||||
qrCodeState = QrCodeState.Loading
|
||||
)
|
||||
|
||||
disposable += usernameRepo.createOrResetUsernameLink()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result ->
|
||||
val components: Optional<UsernameLinkComponents> = when (result) {
|
||||
is UsernameLinkResetResult.Success -> Optional.of(result.components)
|
||||
is UsernameLinkResetResult.NetworkError -> Optional.empty()
|
||||
else -> { usernameLink.value ?: Optional.empty() }
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkState = if (components.isPresent) {
|
||||
val link = UsernameUtil.generateLink(components.get())
|
||||
UsernameLinkState.Present(link)
|
||||
} else {
|
||||
UsernameLinkState.NotSet
|
||||
},
|
||||
usernameLinkResetResult = result,
|
||||
qrCodeState = if (components.isPresent && previousQrValue != null) {
|
||||
QrCodeState.Present(previousQrValue)
|
||||
} else {
|
||||
QrCodeState.NotSet
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUsernameLinkResetResultHandled() {
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkResetResult = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onQrCodeScanned(url: String) {
|
||||
_state.value = _state.value.copy(
|
||||
indeterminateProgress = true
|
||||
)
|
||||
|
||||
disposable += Single
|
||||
.fromCallable {
|
||||
val username: String? = UsernameUtil.parseLink(url)
|
||||
|
||||
if (username == null) {
|
||||
Log.w(TAG, "Failed to parse username from url")
|
||||
return@fromCallable QrScanResult.InvalidData
|
||||
}
|
||||
|
||||
return@fromCallable try {
|
||||
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
|
||||
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
|
||||
QrScanResult.Success(Recipient.externalUsername(aci, username))
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Invalid username", e)
|
||||
QrScanResult.InvalidData
|
||||
} catch (e: NonSuccessfulResponseCodeException) {
|
||||
Log.w(TAG, "Non-successful response during username resolution", e)
|
||||
if (e.code == 404) {
|
||||
QrScanResult.NotFound(username)
|
||||
} else {
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during username resolution", e)
|
||||
QrScanResult.NetworkError
|
||||
disposable += usernameRepo.convertLinkToUsernameAndAci(url)
|
||||
.map { result ->
|
||||
when (result) {
|
||||
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
|
||||
is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
|
||||
is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
|
||||
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
@@ -119,9 +152,9 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateQrCodeData(url: String): Single<QrCodeData> {
|
||||
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
|
||||
return Single.fromCallable {
|
||||
QrCodeData.forData(url, 64)
|
||||
url.map { QrCodeData.forData(it, 64) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -31,14 +35,15 @@ import androidx.navigation.NavController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -48,22 +53,43 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@Composable
|
||||
fun UsernameLinkShareScreen(
|
||||
state: UsernameLinkSettingsState,
|
||||
onLinkResultHandled: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
navController: NavController,
|
||||
onShareBadge: (Bitmap) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null
|
||||
screenshotController: ScreenshotController? = null,
|
||||
onResetClicked: () -> Unit
|
||||
) {
|
||||
when (state.usernameLinkResetResult) {
|
||||
UsernameLinkResetResult.NetworkUnavailable -> {
|
||||
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled)
|
||||
}
|
||||
UsernameLinkResetResult.NetworkError -> {
|
||||
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_error), onDismiss = onLinkResultHandled)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast)
|
||||
QrCodeBadge(
|
||||
data = state.qrCodeData,
|
||||
data = state.qrCodeState,
|
||||
colorScheme = state.qrCodeColorScheme,
|
||||
username = state.username,
|
||||
screenshotController = screenshotController
|
||||
screenshotController = screenshotController,
|
||||
usernameCopyable = true,
|
||||
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(usernameCopiedString)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ButtonBar(
|
||||
@@ -76,16 +102,8 @@ fun UsernameLinkShareScreen(
|
||||
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
|
||||
)
|
||||
|
||||
CopyRow(
|
||||
displayText = state.username,
|
||||
copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast),
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
)
|
||||
|
||||
CopyRow(
|
||||
displayText = state.usernameLink,
|
||||
copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast),
|
||||
LinkRow(
|
||||
linkState = state.usernameLinkState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
)
|
||||
@@ -94,7 +112,7 @@ fun UsernameLinkShareScreen(
|
||||
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp),
|
||||
modifier = Modifier.padding(bottom = 19.dp, start = 43.dp, end = 43.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -104,7 +122,7 @@ fun UsernameLinkShareScreen(
|
||||
.padding(bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Buttons.Small(onClick = { /*TODO*/ }) {
|
||||
Buttons.Small(onClick = onResetClicked) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label)
|
||||
)
|
||||
@@ -133,29 +151,46 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
|
||||
private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
|
||||
val context = LocalContext.current
|
||||
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.clickable {
|
||||
Util.copyToClipboard(context, displayText)
|
||||
.padding(
|
||||
top = 32.dp,
|
||||
bottom = 24.dp,
|
||||
start = 24.dp,
|
||||
end = 24.dp
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable(enabled = linkState is UsernameLinkState.Present) {
|
||||
Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link)
|
||||
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(copyMessage)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 26.dp, vertical = 16.dp)
|
||||
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
painter = painterResource(id = R.drawable.symbol_link_24),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
text = when (linkState) {
|
||||
is UsernameLinkState.Present -> linkState.link
|
||||
is UsernameLinkState.NotSet -> stringResource(id = R.string.UsernameLinkSettings_link_not_set_label)
|
||||
is UsernameLinkState.Resetting -> stringResource(id = R.string.UsernameLinkSettings_resetting_link_label)
|
||||
},
|
||||
modifier = Modifier.padding(start = 26.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -163,45 +198,68 @@ private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState:
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme")
|
||||
@Composable
|
||||
private fun ScreenPreviewLightTheme() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun ResetLinkResultDialog(message: String, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(id = android.R.string.ok),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ScreenPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
UsernameLinkShareScreen(
|
||||
state = previewState(),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
navController = NavController(LocalContext.current),
|
||||
onShareBadge = {}
|
||||
onShareBadge = {},
|
||||
onResetClicked = {},
|
||||
onLinkResultHandled = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Dark Theme")
|
||||
@Preview(name = "Light Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ScreenPreviewDarkTheme() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
private fun LinkRowPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
UsernameLinkShareScreen(
|
||||
state = previewState(),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
navController = NavController(LocalContext.current),
|
||||
onShareBadge = {}
|
||||
)
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.NotSet,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Resetting,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewState(): UsernameLinkSettingsState {
|
||||
val link = UsernameUtil.generateLink("maya.45")
|
||||
val link = "https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
|
||||
return UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = "maya.45",
|
||||
usernameLink = link,
|
||||
qrCodeData = QrCodeData.forData(link, 64),
|
||||
username = "parker.42",
|
||||
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
|
||||
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
sealed class UsernameLinkState {
|
||||
|
||||
/** Link is set. */
|
||||
data class Present(val link: String) : UsernameLinkState()
|
||||
|
||||
/** Link has not been set yet or otherwise does not exist. */
|
||||
object NotSet : UsernameLinkState()
|
||||
|
||||
/** Link is in the process of being reset. */
|
||||
object Resetting : UsernameLinkState()
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to start a chat.
|
||||
@@ -53,7 +54,11 @@ fun UsernameQrScanScreen(
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.NotFound -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
if (qrScanResult.username != null) {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
} else {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled)
|
||||
}
|
||||
}
|
||||
is QrScanResult.Success -> {
|
||||
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
|
||||
@@ -70,7 +75,7 @@ fun UsernameQrScanScreen(
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val view = QrScannerView(context)
|
||||
disposables += view.qrData.distinctUntilChanged().subscribe { data ->
|
||||
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
|
||||
@@ -543,14 +543,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
|
||||
}
|
||||
)
|
||||
if (!state.recipient.isReleaseNotes && !state.recipient.isSelf) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
|
||||
@@ -584,7 +586,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { recipientSettingsState ->
|
||||
if (state.recipient.badges.isNotEmpty()) {
|
||||
if (state.recipient.badges.isNotEmpty() && !state.recipient.isSelf) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.ManageProfileFragment_badges)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.databinding.DslBannerBinding
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
|
||||
/**
|
||||
* Displays a banner to notify the user of certain state or action that needs to be taken.
|
||||
*/
|
||||
object Banner {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, DslBannerBinding::inflate))
|
||||
}
|
||||
|
||||
class Model(
|
||||
@StringRes val textId: Int,
|
||||
@StringRes val actionId: Int,
|
||||
val onClick: () -> Unit
|
||||
) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return textId == newItem.textId && actionId == newItem.actionId
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(binding: DslBannerBinding) : BindingViewHolder<Model, DslBannerBinding>(binding) {
|
||||
override fun bind(model: Model) {
|
||||
binding.bannerText.setText(model.textId)
|
||||
binding.bannerAction.setText(model.actionId)
|
||||
binding.bannerAction.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||
import com.google.android.exoplayer2.audio.AudioSink
|
||||
import com.google.android.exoplayer2.audio.DefaultAudioSink
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@@ -12,6 +14,7 @@ import java.nio.ByteBuffer
|
||||
* It does eventually recover, but it needs to be given ample opportunity to.
|
||||
* This class wraps the final DefaultAudioSink to provide exactly that functionality.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class RetryableInitAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.MediaMetadataCompat;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Encapsulates control of voice note playback from an Activity component.
|
||||
* <p>
|
||||
* This class assumes that it will be created within the scope of Activity#onCreate
|
||||
* <p>
|
||||
* The workhorse of this repository is the ProgressEventHandler, which will supply a
|
||||
* steady stream of update events to the set callback.
|
||||
*/
|
||||
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
|
||||
public static final String EXTRA_PROGRESS = "voice.note.playhead";
|
||||
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
|
||||
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private FragmentActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
|
||||
private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager;
|
||||
private boolean isMediaBrowserCreationPostponed;
|
||||
|
||||
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
|
||||
|
||||
public VoiceNoteMediaController(@NonNull FragmentActivity activity) {
|
||||
this(activity, false);
|
||||
}
|
||||
|
||||
public VoiceNoteMediaController(@NonNull FragmentActivity activity, boolean postponeMediaBrowserCreation) {
|
||||
this.activity = activity;
|
||||
this.isMediaBrowserCreationPostponed = postponeMediaBrowserCreation;
|
||||
|
||||
activity.getLifecycle().addObserver(this);
|
||||
|
||||
voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> {
|
||||
if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) {
|
||||
VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType();
|
||||
LiveRecipient sender = Recipient.live(message.getSenderId());
|
||||
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
|
||||
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
|
||||
threadRecipient.getLiveDataResolved(),
|
||||
(s, t) -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null));
|
||||
|
||||
return Transformations.map(name, displayName -> Optional.of(
|
||||
new VoiceNotePlayerView.State(
|
||||
playbackState.getUri(),
|
||||
message.getMessageId(),
|
||||
message.getThreadId(),
|
||||
!playbackState.isPlaying(),
|
||||
message.getSenderId(),
|
||||
message.getThreadRecipientId(),
|
||||
message.getMessagePosition(),
|
||||
message.getTimestamp(),
|
||||
displayName,
|
||||
playbackState.getPlayheadPositionMillis(),
|
||||
playbackState.getTrackDuration(),
|
||||
playbackState.getSpeed())));
|
||||
} else {
|
||||
return new DefaultValueLiveData<>(Optional.empty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ensureMediaBrowser() {
|
||||
if (mediaBrowser != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mediaBrowser = new MediaBrowserCompat(activity,
|
||||
new ComponentName(activity, VoiceNotePlaybackService.class),
|
||||
new ConnectionCallback(),
|
||||
null);
|
||||
}
|
||||
|
||||
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
|
||||
return voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
|
||||
return voiceNotePlayerViewState;
|
||||
}
|
||||
|
||||
public void finishPostpone() {
|
||||
isMediaBrowserCreationPostponed = false;
|
||||
if (activity != null && mediaBrowser == null && activity.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
ensureMediaBrowser();
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume(@NonNull LifecycleOwner owner) {
|
||||
if (mediaBrowser == null && isMediaBrowserCreationPostponed) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMediaBrowser();
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull LifecycleOwner owner) {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (MediaControllerCompat.getMediaController(activity) != null) {
|
||||
MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback);
|
||||
}
|
||||
|
||||
if (mediaBrowser != null) {
|
||||
mediaBrowser.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(@NonNull LifecycleOwner owner) {
|
||||
if (voiceNoteProximityWakeLockManager != null) {
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
|
||||
voiceNoteProximityWakeLockManager = null;
|
||||
}
|
||||
|
||||
activity.getLifecycle().removeObserver(this);
|
||||
activity = null;
|
||||
}
|
||||
|
||||
private static boolean isPlayerActive(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_BUFFERING ||
|
||||
playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING;
|
||||
}
|
||||
|
||||
private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED;
|
||||
}
|
||||
|
||||
private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED;
|
||||
}
|
||||
|
||||
private @Nullable MediaControllerCompat getMediaController() {
|
||||
if (activity != null) {
|
||||
return MediaControllerCompat.getMediaController(activity);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, false);
|
||||
}
|
||||
|
||||
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, true);
|
||||
}
|
||||
|
||||
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
|
||||
startPlayback(draftUri, -1, threadId, progress, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to begin playback of a given audio slide. If the audio
|
||||
* slide is currently playing, we jump to the desired position and then begin playback.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called startPlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
getMediaController().getTransportControls().seekTo((long) (duration * progress));
|
||||
getMediaController().getTransportControls().play();
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putDouble(EXTRA_PROGRESS, progress);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
|
||||
|
||||
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
|
||||
* currently paused, playback will be started from the beginning.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
*/
|
||||
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called resumePlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().play();
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, -1L);
|
||||
extras.putDouble(EXTRA_PROGRESS, 0.0);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
|
||||
|
||||
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback if the given audio slide is playing.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to pause.
|
||||
*/
|
||||
public void pausePlayback(@NonNull Uri audioSlideUri) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called pausePlayback(uri) before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback regardless of which audio slide is playing.
|
||||
*/
|
||||
public void pausePlayback() {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called pausePlayback before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
getMediaController().getTransportControls().pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks to a given position if th given audio slide is playing. This call
|
||||
* is ignored if the given audio slide is not currently playing.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to seek.
|
||||
* @param progress The progress percentage to seek to.
|
||||
*/
|
||||
public void seekToPosition(@NonNull Uri audioSlideUri, double progress) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called seekToPosition before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
getMediaController().getTransportControls().pause();
|
||||
getMediaController().getTransportControls().seekTo((long) (duration * progress));
|
||||
getMediaController().getTransportControls().play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops playback if the given audio slide is playing
|
||||
*
|
||||
* @param audioSlideUri The Uri of the audio slide to stop
|
||||
*/
|
||||
public void stopPlaybackAndReset(@NonNull Uri audioSlideUri) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called stopPlaybackAndReset before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called setPlaybackSpeed before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed);
|
||||
|
||||
getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentTrack(@NonNull Uri uri) {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called isCurrentTrack before controller was set. (" + getActivityName() + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
|
||||
|
||||
return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri);
|
||||
}
|
||||
|
||||
private void notifyProgressEventHandler() {
|
||||
if (getMediaController() == null) {
|
||||
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (" + getActivityName() + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressEventHandler == null && activity != null) {
|
||||
progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState);
|
||||
progressEventHandler.sendEmptyMessage(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearProgressEventHandler() {
|
||||
if (progressEventHandler != null) {
|
||||
progressEventHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getActivityName() {
|
||||
if (activity == null) {
|
||||
return "Activity is null";
|
||||
} else {
|
||||
return activity.getLocalClassName();
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
MediaSessionCompat.Token token = mediaBrowser.getSessionToken();
|
||||
MediaControllerCompat mediaController = new MediaControllerCompat(activity, token);
|
||||
|
||||
MediaControllerCompat.setMediaController(activity, mediaController);
|
||||
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
|
||||
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
|
||||
|
||||
if (newState != null) {
|
||||
voiceNotePlaybackState.postValue(newState);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
|
||||
|
||||
mediaController.registerCallback(mediaControllerCompatCallback);
|
||||
|
||||
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionSuspended() {
|
||||
Log.d(TAG, "Voice note MediaBrowser connection suspended.");
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionFailed() {
|
||||
Log.d(TAG, "Voice note MediaBrowser connection failed.");
|
||||
cleanUpOldProximityWakeLockManager();
|
||||
}
|
||||
|
||||
private void cleanUpOldProximityWakeLockManager() {
|
||||
if (voiceNoteProximityWakeLockManager != null) {
|
||||
Log.d(TAG, "Session reconnected, cleaning up old wake lock manager");
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
|
||||
voiceNoteProximityWakeLockManager = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
|
||||
return mediaMetadataCompat != null &&
|
||||
mediaMetadataCompat.getDescription() != null &&
|
||||
mediaMetadataCompat.getDescription().getMediaUri() != null;
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull MediaMetadataCompat mediaMetadataCompat,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
|
||||
boolean autoReset = Objects.equals(mediaUri, VoiceNoteMediaItemFactory.NEXT_URI) || Objects.equals(mediaUri, VoiceNoteMediaItemFactory.END_URI);
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
Bundle extras = mediaController.getExtras();
|
||||
float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f;
|
||||
|
||||
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
|
||||
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
|
||||
position = previousState.getPlayheadPositionMillis();
|
||||
}
|
||||
|
||||
if (duration <= 0 && previousState.getTrackDuration() > 0) {
|
||||
duration = previousState.getTrackDuration();
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0 && position >= 0 && position <= duration) {
|
||||
return new VoiceNotePlaybackState(mediaUri,
|
||||
position,
|
||||
duration,
|
||||
autoReset,
|
||||
speed,
|
||||
isPlayerActive(mediaController.getPlaybackState()),
|
||||
getClipType(mediaMetadataCompat.getBundle()));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (isPlayerActive(mediaController.getPlaybackState()) &&
|
||||
canExtractPlaybackInformationFromMetadata(mediaMetadataCompat))
|
||||
{
|
||||
return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState);
|
||||
} else if (isPlayerPaused(mediaController.getPlaybackState()) &&
|
||||
mediaMetadataCompat != null)
|
||||
{
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
if (previousState != null && position < duration) {
|
||||
return previousState.asPaused();
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) {
|
||||
long messageId = -1L;
|
||||
RecipientId senderId = RecipientId.UNKNOWN;
|
||||
long messagePosition = -1L;
|
||||
long threadId = -1L;
|
||||
RecipientId threadRecipientId = RecipientId.UNKNOWN;
|
||||
long timestamp = -1L;
|
||||
|
||||
if (mediaExtras != null) {
|
||||
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L);
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L);
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L);
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
|
||||
|
||||
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId);
|
||||
}
|
||||
|
||||
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
|
||||
if (serializedThreadRecipientId != null) {
|
||||
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageId != -1L) {
|
||||
return new VoiceNotePlaybackState.ClipType.Message(messageId,
|
||||
senderId,
|
||||
threadRecipientId,
|
||||
messagePosition,
|
||||
threadId,
|
||||
timestamp);
|
||||
} else {
|
||||
return VoiceNotePlaybackState.ClipType.Draft.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProgressEventHandler extends Handler {
|
||||
|
||||
private final MediaControllerCompat mediaController;
|
||||
private final MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState;
|
||||
|
||||
private ProgressEventHandler(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState)
|
||||
{
|
||||
super(Looper.getMainLooper());
|
||||
|
||||
this.mediaController = mediaController;
|
||||
this.voiceNotePlaybackState = voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(@NonNull Message msg) {
|
||||
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
|
||||
|
||||
if (newPlaybackState != null) {
|
||||
voiceNotePlaybackState.postValue(newPlaybackState);
|
||||
}
|
||||
|
||||
if (isPlayerActive(mediaController.getPlaybackState())) {
|
||||
sendEmptyMessageDelayed(0, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback {
|
||||
@Override
|
||||
public void onPlaybackStateChanged(PlaybackStateCompat state) {
|
||||
if (isPlayerActive(state)) {
|
||||
notifyProgressEventHandler();
|
||||
} else {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (isPlayerStopped(state)) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.switchMap
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* This is a lifecycle-aware wrapper for the [MediaController].
|
||||
* Its main responsibilities are broadcasting playback state through [LiveData],
|
||||
* and resolving metadata values for a audio clip's URI into a [MediaItem] that media3 can understand.
|
||||
*/
|
||||
class VoiceNoteMediaController(val activity: FragmentActivity, private var postponeMediaControllerCreation: Boolean) : DefaultLifecycleObserver {
|
||||
|
||||
val voiceNotePlaybackState = MutableLiveData(VoiceNotePlaybackState.NONE)
|
||||
val voiceNotePlayerViewState: LiveData<Optional<VoiceNotePlayerView.State>>
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
private var mediaControllerProperty: MediaController? = null
|
||||
private lateinit var voiceNoteProximityWakeLockManager: VoiceNoteProximityWakeLockManager
|
||||
private var progressEventHandler: ProgressEventHandler? = null
|
||||
private var queuedPlayback: PlaybackItem? = null
|
||||
|
||||
init {
|
||||
activity.lifecycle.addObserver(this)
|
||||
|
||||
voiceNotePlayerViewState = voiceNotePlaybackState.switchMap { (uri, playheadPositionMillis, trackDuration, _, speed, isPlaying, clipType): VoiceNotePlaybackState ->
|
||||
if (clipType is VoiceNotePlaybackState.ClipType.Message) {
|
||||
val (messageId, senderId, threadRecipientId, messagePosition, threadId, timestamp) = clipType
|
||||
val sender = Recipient.live(senderId)
|
||||
val threadRecipient = Recipient.live(threadRecipientId)
|
||||
val name = LiveDataUtil.combineLatest(
|
||||
sender.liveDataResolved,
|
||||
threadRecipient.liveDataResolved
|
||||
) { s: Recipient, t: Recipient -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null) }
|
||||
|
||||
return@switchMap name.map<String, Optional<VoiceNotePlayerView.State>> { displayName: String ->
|
||||
Optional.of<VoiceNotePlayerView.State>(
|
||||
VoiceNotePlayerView.State(
|
||||
uri,
|
||||
messageId,
|
||||
threadId,
|
||||
!isPlaying,
|
||||
senderId,
|
||||
threadRecipientId,
|
||||
messagePosition,
|
||||
timestamp,
|
||||
displayName,
|
||||
playheadPositionMillis,
|
||||
trackDuration,
|
||||
speed
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return@switchMap DefaultValueLiveData<Optional<VoiceNotePlayerView.State>>(Optional.empty<VoiceNotePlayerView.State>())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
super.onStart(owner)
|
||||
if (mediaControllerProperty == null && postponeMediaControllerCreation) {
|
||||
Log.i(TAG, "Postponing media controller creation. (${activity.localClassName}})")
|
||||
return
|
||||
}
|
||||
|
||||
createMediaControllerAsync()
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
super.onResume(owner)
|
||||
|
||||
progressEventHandler?.sendEmptyMessage(0)
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
clearProgressEventHandler()
|
||||
super.onPause(owner)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
mediaControllerProperty?.release()
|
||||
mediaControllerProperty = null
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
if (this::voiceNoteProximityWakeLockManager.isInitialized) {
|
||||
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease()
|
||||
voiceNoteProximityWakeLockManager.unregisterFromLifecycle()
|
||||
}
|
||||
activity.lifecycle.removeObserver(this)
|
||||
super.onDestroy(owner)
|
||||
}
|
||||
|
||||
fun finishPostpone() {
|
||||
if (mediaControllerProperty == null && postponeMediaControllerCreation && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
Log.i(TAG, "Finishing postponed media controller creation. (${activity.localClassName}})")
|
||||
createMediaControllerAsync()
|
||||
} else {
|
||||
Log.w(TAG, "Could not finish postponed media controller creation! (${activity.localClassName}})")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaControllerAsync() {
|
||||
val applicationContext = activity.applicationContext
|
||||
val voiceNotePlaybackServiceSessionToken = SessionToken(applicationContext, ComponentName(applicationContext, VoiceNotePlaybackService::class.java))
|
||||
val mediaControllerBuilder = MediaController.Builder(applicationContext, voiceNotePlaybackServiceSessionToken)
|
||||
Observable.fromFuture(mediaControllerBuilder.buildAsync())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
initializeMediaController(it)
|
||||
}
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
private fun initializeMediaController(uninitializedMediaController: MediaController) {
|
||||
postponeMediaControllerCreation = false
|
||||
|
||||
voiceNoteProximityWakeLockManager = VoiceNoteProximityWakeLockManager(activity, uninitializedMediaController)
|
||||
uninitializedMediaController.addListener(PlaybackStateListener())
|
||||
Log.d(TAG, "MediaController successfully initialized. (${activity.localClassName})")
|
||||
mediaControllerProperty = uninitializedMediaController
|
||||
queuedPlayback?.let { startPlayback(it) }
|
||||
queuedPlayback = null
|
||||
notifyProgressEventHandler()
|
||||
}
|
||||
|
||||
private fun notifyProgressEventHandler() {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (${activity.localClassName})")
|
||||
return
|
||||
}
|
||||
if (progressEventHandler == null) {
|
||||
progressEventHandler = ProgressEventHandler(mediaController, voiceNotePlaybackState)
|
||||
}
|
||||
progressEventHandler?.sendEmptyMessage(0)
|
||||
}
|
||||
|
||||
private fun clearProgressEventHandler() {
|
||||
progressEventHandler = null
|
||||
}
|
||||
|
||||
fun startConsecutivePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, false))
|
||||
}
|
||||
|
||||
fun startSinglePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, true))
|
||||
}
|
||||
|
||||
fun startSinglePlaybackForDraft(draftUri: Uri, threadId: Long, progress: Double) {
|
||||
startPlayback(PlaybackItem(draftUri, -1, threadId, progress, true))
|
||||
}
|
||||
|
||||
fun resumePlayback(audioSlideUri: Uri, messageId: Long) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to resume playback before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.play()
|
||||
} else {
|
||||
startSinglePlayback(audioSlideUri, messageId, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun pausePlayback(audioSlideUri: Uri) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
pausePlayback()
|
||||
} else {
|
||||
Log.i(TAG, "Tried to pause $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to pause playback before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
mediaController.pause()
|
||||
}
|
||||
|
||||
fun seekToPosition(audioSlideUri: Uri, progress: Double) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to seekToPosition before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.seekTo((mediaController.duration * progress).toLong())
|
||||
} else {
|
||||
Log.i(TAG, "Tried to seek $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPlaybackAndReset(audioSlideUri: Uri) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to stopPlaybackAndReset before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.stop()
|
||||
} else {
|
||||
Log.i(TAG, "Tried to stop $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(audioSlideUri: Uri, playbackSpeed: Float) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to set playback speed before the media controller was ready.")
|
||||
return
|
||||
}
|
||||
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
mediaController.setPlaybackSpeed(playbackSpeed)
|
||||
} else {
|
||||
Log.i(TAG, "Tried to set playback speed of $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to begin playback of a given audio slide. If the audio
|
||||
* slide is currently playing, we jump to the desired position and then begin playback.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private fun startPlayback(playbackItem: PlaybackItem) {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Tried to start playback before the media controller was ready.")
|
||||
queuedPlayback = playbackItem
|
||||
return
|
||||
}
|
||||
|
||||
if (isCurrentTrack(playbackItem.audioSlideUri)) {
|
||||
val duration: Long = mediaController.duration
|
||||
mediaController.seekTo((duration * playbackItem.progress).toLong())
|
||||
mediaController.play()
|
||||
} else {
|
||||
val extras = bundleOf(
|
||||
EXTRA_MESSAGE_ID to playbackItem.messageId,
|
||||
EXTRA_THREAD_ID to playbackItem.threadId,
|
||||
EXTRA_PROGRESS to playbackItem.progress,
|
||||
EXTRA_PLAY_SINGLE to playbackItem.singlePlayback
|
||||
)
|
||||
val requestMetadata = RequestMetadata.Builder().setMediaUri(playbackItem.audioSlideUri).setExtras(extras).build()
|
||||
if (playbackItem.singlePlayback) {
|
||||
mediaController.clearMediaItems()
|
||||
}
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(playbackItem.audioSlideUri)
|
||||
.setRequestMetadata(requestMetadata).build()
|
||||
mediaController.addMediaItem(mediaItem)
|
||||
mediaController.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentTrack(uri: Uri): Boolean {
|
||||
val mediaController = mediaControllerProperty
|
||||
if (mediaController == null) {
|
||||
Log.w(TAG, "Called isCurrentTrack before media controller was set. (${activity.localClassName}})")
|
||||
return false
|
||||
}
|
||||
return uri == getCurrentlyPlayingUri()
|
||||
}
|
||||
|
||||
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
private fun getCurrentlyPlayingUri(): Uri? = mediaControllerProperty?.currentMediaItem?.requestMetadata?.mediaUri
|
||||
|
||||
inner class PlaybackStateListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
notifyProgressEventHandler()
|
||||
} else {
|
||||
clearProgressEventHandler()
|
||||
if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressEventHandler(
|
||||
private val mediaController: MediaController,
|
||||
private val voiceNotePlaybackState: MutableLiveData<VoiceNotePlaybackState>
|
||||
) : Handler(Looper.getMainLooper()) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
val newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.value)
|
||||
voiceNotePlaybackState.postValue(newPlaybackState)
|
||||
val playerActive = mediaController.isPlaying
|
||||
if (playerActive) {
|
||||
sendEmptyMessageDelayed(0, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
|
||||
|
||||
var EXTRA_THREAD_ID = "voice.note.thread_id"
|
||||
var EXTRA_MESSAGE_ID = "voice.note.message_id"
|
||||
var EXTRA_PROGRESS = "voice.note.playhead"
|
||||
var EXTRA_PLAY_SINGLE = "voice.note.play.single"
|
||||
|
||||
@JvmStatic
|
||||
private fun constructPlaybackState(
|
||||
mediaController: MediaController,
|
||||
previousState: VoiceNotePlaybackState?
|
||||
): VoiceNotePlaybackState {
|
||||
val mediaUri = mediaController.currentMediaItem?.requestMetadata?.mediaUri
|
||||
return if (mediaController.isPlaying &&
|
||||
mediaUri != null
|
||||
) {
|
||||
extractStateFromMetadata(mediaController, mediaUri, previousState)
|
||||
} else if (mediaController.playbackState == Player.STATE_READY && !mediaController.playWhenReady) {
|
||||
val position = mediaController.currentPosition
|
||||
val duration = mediaController.contentDuration
|
||||
if (previousState != null && position < duration) {
|
||||
previousState.asPaused()
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun extractStateFromMetadata(
|
||||
mediaController: MediaController,
|
||||
mediaUri: Uri,
|
||||
previousState: VoiceNotePlaybackState?
|
||||
): VoiceNotePlaybackState {
|
||||
val speed = mediaController.playbackParameters.speed
|
||||
var duration = mediaController.contentDuration
|
||||
val mediaMetadata = mediaController.mediaMetadata
|
||||
var position = mediaController.currentPosition
|
||||
val autoReset = mediaUri == VoiceNoteMediaItemFactory.NEXT_URI || mediaUri == VoiceNoteMediaItemFactory.END_URI
|
||||
if (previousState != null && mediaUri == previousState.uri) {
|
||||
if (position < 0 && previousState.playheadPositionMillis >= 0) {
|
||||
position = previousState.playheadPositionMillis
|
||||
}
|
||||
if (duration <= 0 && previousState.trackDuration > 0) {
|
||||
duration = previousState.trackDuration
|
||||
}
|
||||
}
|
||||
return if (duration > 0 && position >= 0 && position <= duration) {
|
||||
VoiceNotePlaybackState(
|
||||
mediaUri,
|
||||
position,
|
||||
duration,
|
||||
autoReset,
|
||||
speed,
|
||||
mediaController.isPlaying,
|
||||
getClipType(mediaMetadata.extras)
|
||||
)
|
||||
} else {
|
||||
VoiceNotePlaybackState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun getClipType(mediaExtras: Bundle?): VoiceNotePlaybackState.ClipType {
|
||||
var messageId = -1L
|
||||
var senderId = RecipientId.UNKNOWN
|
||||
var messagePosition = -1L
|
||||
var threadId = -1L
|
||||
var threadRecipientId = RecipientId.UNKNOWN
|
||||
var timestamp = -1L
|
||||
if (mediaExtras != null) {
|
||||
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L)
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L)
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L)
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L)
|
||||
val serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID)
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId)
|
||||
}
|
||||
val serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID)
|
||||
if (serializedThreadRecipientId != null) {
|
||||
threadRecipientId = RecipientId.from(serializedThreadRecipientId)
|
||||
}
|
||||
}
|
||||
return if (messageId != -1L) {
|
||||
VoiceNotePlaybackState.ClipType.Message(
|
||||
messageId,
|
||||
senderId!!,
|
||||
threadRecipientId!!,
|
||||
messagePosition,
|
||||
threadId,
|
||||
timestamp
|
||||
)
|
||||
} else {
|
||||
VoiceNotePlaybackState.ClipType.Draft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holder class that contains everything one might need to begin voice note playback. Useful for queueing up items to play when the media controller is being initialized.
|
||||
*/
|
||||
data class PlaybackItem(val audioSlideUri: Uri, val messageId: Long, val threadId: Long, val progress: Double, val singlePlayback: Boolean)
|
||||
}
|
||||
@@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.components.voice;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.MediaMetadata;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -144,22 +142,21 @@ class VoiceNoteMediaItemFactory {
|
||||
}
|
||||
|
||||
return new MediaItem.Builder()
|
||||
.setUri(audioUri)
|
||||
.setMediaMetadata(
|
||||
new MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.setTag(
|
||||
new MediaDescriptionCompat.Builder()
|
||||
.setMediaUri(audioUri)
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build())
|
||||
.build();
|
||||
.setUri(audioUri)
|
||||
.setMediaMetadata(
|
||||
new MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.setRequestMetadata(
|
||||
new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(audioUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
|
||||
@@ -191,18 +188,16 @@ class VoiceNoteMediaItemFactory {
|
||||
}
|
||||
|
||||
private static MediaItem cloneMediaItem(MediaItem source, String mediaId, Uri uri) {
|
||||
MediaDescriptionCompat description = source.playbackProperties != null ? (MediaDescriptionCompat) source.playbackProperties.tag : null;
|
||||
Bundle requestExtras = source.requestMetadata.extras;
|
||||
return source.buildUpon()
|
||||
.setMediaId(mediaId)
|
||||
.setUri(uri)
|
||||
.setTag(
|
||||
description != null ?
|
||||
new MediaDescriptionCompat.Builder()
|
||||
.setMediaMetadata(source.mediaMetadata)
|
||||
.setRequestMetadata(
|
||||
new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setTitle(description.getTitle())
|
||||
.setSubtitle(description.getSubtitle())
|
||||
.setExtras(description.getExtras())
|
||||
.build() : null)
|
||||
.setExtras(requestExtras)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.google.common.collect.ImmutableList
|
||||
import org.signal.core.util.PendingIntentFlags.cancelCurrent
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil
|
||||
import java.util.Arrays
|
||||
|
||||
/**
|
||||
* This handles all of the notification and playback APIs for playing back a voice note.
|
||||
* It integrates, using [androidx.media.app.NotificationCompat.MediaStyle], with the system's media controls.
|
||||
*/
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
class VoiceNoteMediaNotificationProvider(val context: Context) : MediaNotification.Provider {
|
||||
private val notificationChannel: String = NotificationChannels.getInstance().VOICE_NOTES
|
||||
private var cachedRecipientId: RecipientId? = null
|
||||
private var cachedBitmap: Bitmap? = null
|
||||
|
||||
override fun createNotification(mediaSession: MediaSession, customLayout: ImmutableList<CommandButton>, actionFactory: MediaNotification.ActionFactory, onNotificationChangedCallback: MediaNotification.Provider.Callback): MediaNotification {
|
||||
val player = mediaSession.player
|
||||
val builder = NotificationCompat.Builder(context, notificationChannel)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColorized(true)
|
||||
if (player.isCommandAvailable(Player.COMMAND_GET_METADATA)) {
|
||||
val metadata: MediaMetadata = player.mediaMetadata
|
||||
builder
|
||||
.setContentTitle(metadata.title)
|
||||
.setContentText(metadata.subtitle)
|
||||
}
|
||||
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||
val compactViewIndices: IntArray = addNotificationActions(
|
||||
mediaSession,
|
||||
getMediaButtons(
|
||||
player.availableCommands,
|
||||
customLayout,
|
||||
player.playWhenReady &&
|
||||
player.playbackState != Player.STATE_ENDED
|
||||
),
|
||||
builder,
|
||||
actionFactory
|
||||
)
|
||||
mediaStyle.setShowActionsInCompactView(*compactViewIndices)
|
||||
|
||||
if (player.isCommandAvailable(Player.COMMAND_STOP)) {
|
||||
mediaStyle.setCancelButtonIntent(
|
||||
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
|
||||
)
|
||||
}
|
||||
val extras = mediaSession.player.mediaMetadata.extras
|
||||
|
||||
if (extras != null) {
|
||||
var color = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR).toInt()
|
||||
if (color == 0) {
|
||||
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor()
|
||||
}
|
||||
builder.color = color
|
||||
|
||||
val pendingIntent = createCurrentContentIntent(extras)
|
||||
builder.setContentIntent(pendingIntent)
|
||||
} else {
|
||||
Log.w(TAG, "Could not populate notification: request metadata extras were null.")
|
||||
}
|
||||
builder.setDeleteIntent(
|
||||
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
|
||||
)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setStyle(mediaStyle)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(false)
|
||||
addLargeIcon(builder, extras, onNotificationChangedCallback)
|
||||
|
||||
return MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrowed from [DefaultMediaNotificationProvider]
|
||||
*/
|
||||
private fun addNotificationActions(
|
||||
mediaSession: MediaSession?,
|
||||
mediaButtons: ImmutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
var compactViewIndices = IntArray(3)
|
||||
val defaultCompactViewIndices = IntArray(3)
|
||||
Arrays.fill(compactViewIndices, C.INDEX_UNSET)
|
||||
Arrays.fill(defaultCompactViewIndices, C.INDEX_UNSET)
|
||||
var compactViewCommandCount = 0
|
||||
for (i in mediaButtons.indices) {
|
||||
val commandButton = mediaButtons[i]
|
||||
if (commandButton.sessionCommand != null) {
|
||||
builder.addAction(
|
||||
actionFactory.createCustomActionFromCustomCommandButton(mediaSession!!, commandButton)
|
||||
)
|
||||
} else {
|
||||
Assertions.checkState(commandButton.playerCommand != Player.COMMAND_INVALID)
|
||||
builder.addAction(
|
||||
actionFactory.createMediaAction(
|
||||
mediaSession!!,
|
||||
IconCompat.createWithResource(context, commandButton.iconResId),
|
||||
commandButton.displayName,
|
||||
commandButton.playerCommand
|
||||
)
|
||||
)
|
||||
}
|
||||
if (compactViewCommandCount == 3) {
|
||||
continue
|
||||
}
|
||||
val compactViewIndex = commandButton.extras.getInt(
|
||||
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX,
|
||||
C.INDEX_UNSET
|
||||
)
|
||||
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.size) {
|
||||
compactViewCommandCount++
|
||||
compactViewIndices[compactViewIndex] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS ||
|
||||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
|
||||
) {
|
||||
defaultCompactViewIndices[0] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_PLAY_PAUSE) {
|
||||
defaultCompactViewIndices[1] = i
|
||||
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT ||
|
||||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM
|
||||
) {
|
||||
defaultCompactViewIndices[2] = i
|
||||
}
|
||||
}
|
||||
if (compactViewCommandCount == 0) {
|
||||
// If there is no custom configuration we use the seekPrev (if any), play/pause (if any),
|
||||
// seekNext (if any) action in compact view.
|
||||
var indexInCompactViewIndices = 0
|
||||
for (i in defaultCompactViewIndices.indices) {
|
||||
if (defaultCompactViewIndices[i] == C.INDEX_UNSET) {
|
||||
continue
|
||||
}
|
||||
compactViewIndices[indexInCompactViewIndices] = defaultCompactViewIndices[i]
|
||||
indexInCompactViewIndices++
|
||||
}
|
||||
}
|
||||
for (i in compactViewIndices.indices) {
|
||||
if (compactViewIndices[i] == C.INDEX_UNSET) {
|
||||
compactViewIndices = compactViewIndices.copyOf(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
return compactViewIndices
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrowed from [DefaultMediaNotificationProvider]
|
||||
*/
|
||||
private fun getMediaButtons(
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
showPauseButton: Boolean
|
||||
): ImmutableList<CommandButton> {
|
||||
val commandButtons = ImmutableList.Builder<CommandButton>()
|
||||
if (playerCommands.containsAny(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.setIconResId(R.drawable.exo_icon_rewind)
|
||||
.setDisplayName(
|
||||
context.getString(R.string.media3_controls_seek_to_previous_description)
|
||||
)
|
||||
.setExtras(commandButtonExtras)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
if (playerCommands.contains(Player.COMMAND_PLAY_PAUSE)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
|
||||
.setIconResId(
|
||||
if (showPauseButton) R.drawable.exo_notification_pause else R.drawable.exo_notification_play
|
||||
)
|
||||
.setExtras(commandButtonExtras)
|
||||
.setDisplayName(
|
||||
if (showPauseButton) context.getString(R.string.media3_controls_pause_description) else context.getString(R.string.media3_controls_play_description)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
if (playerCommands.containsAny(Player.COMMAND_STOP)) {
|
||||
val commandButtonExtras = Bundle()
|
||||
commandButtons.add(
|
||||
CommandButton.Builder()
|
||||
.setPlayerCommand(Player.COMMAND_STOP)
|
||||
.setIconResId(R.drawable.exo_notification_stop)
|
||||
.setExtras(commandButtonExtras)
|
||||
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
for (i in customLayout.indices) {
|
||||
val button = customLayout[i]
|
||||
if (button.sessionCommand != null &&
|
||||
button.sessionCommand!!.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
|
||||
) {
|
||||
commandButtons.add(button)
|
||||
}
|
||||
}
|
||||
return commandButtons.build()
|
||||
}
|
||||
|
||||
private fun createCurrentContentIntent(extras: Bundle): PendingIntent? {
|
||||
val serializedRecipientId = extras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID) ?: return null
|
||||
val recipientId = RecipientId.from(serializedRecipientId)
|
||||
val startingPosition = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION)
|
||||
val threadId = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID)
|
||||
|
||||
val conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
|
||||
.withStartingPosition(startingPosition.toInt())
|
||||
.build()
|
||||
|
||||
conversationActivity.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
conversationActivity,
|
||||
cancelCurrent()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This will either fetch a cached bitmap and add it to the builder immediately,
|
||||
* OR it will set a callback to update the notification once the bitmap is fetched by [AvatarUtil]
|
||||
*/
|
||||
private fun addLargeIcon(builder: NotificationCompat.Builder, extras: Bundle?, callback: MediaNotification.Provider.Callback) {
|
||||
if (extras == null || !SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) {
|
||||
cachedBitmap = null
|
||||
cachedRecipientId = null
|
||||
return
|
||||
}
|
||||
|
||||
val serializedRecipientId: String = extras.getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID) ?: return
|
||||
|
||||
val currentRecipientId = RecipientId.from(serializedRecipientId)
|
||||
|
||||
if (currentRecipientId == cachedRecipientId && cachedBitmap != null) {
|
||||
builder.setLargeIcon(cachedBitmap)
|
||||
} else {
|
||||
cachedRecipientId = currentRecipientId
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
try {
|
||||
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId!!))
|
||||
builder.setLargeIcon(cachedBitmap)
|
||||
callback.onNotificationChanged(MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build()))
|
||||
} catch (e: Exception) {
|
||||
cachedBitmap = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We do not currently support any custom commands in the notification area.
|
||||
*/
|
||||
override fun handleCustomCommand(session: MediaSession, action: String, extras: Bundle): Boolean {
|
||||
throw UnsupportedOperationException("Custom command handler for Notification is unused.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOW_PLAYING_NOTIFICATION_ID = 32221
|
||||
private const val TAG = "VoiceNoteMediaNotificationProvider"
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
class VoiceNoteNotificationManager {
|
||||
|
||||
private static final short NOW_PLAYING_NOTIFICATION_ID = 32221;
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
private final PlayerNotificationManager notificationManager;
|
||||
|
||||
VoiceNoteNotificationManager(@NonNull Context context,
|
||||
@NonNull MediaSessionCompat.Token token,
|
||||
@NonNull PlayerNotificationManager.NotificationListener listener)
|
||||
{
|
||||
this.context = context;
|
||||
controller = new MediaControllerCompat(context, token);
|
||||
notificationManager = new PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NotificationChannels.getInstance().VOICE_NOTES)
|
||||
.setChannelNameResourceId(R.string.NotificationChannel_voice_notes)
|
||||
.setMediaDescriptionAdapter(new DescriptionAdapter())
|
||||
.setNotificationListener(listener)
|
||||
.build();
|
||||
|
||||
notificationManager.setMediaSessionToken(token);
|
||||
notificationManager.setSmallIcon(R.drawable.ic_notification);
|
||||
notificationManager.setColorized(true);
|
||||
notificationManager.setUseFastForwardAction(false);
|
||||
notificationManager.setUseRewindAction(false);
|
||||
notificationManager.setUseStopAction(true);
|
||||
}
|
||||
|
||||
public void hideNotification() {
|
||||
notificationManager.setPlayer(null);
|
||||
}
|
||||
|
||||
public void showNotification(@NonNull Player player) {
|
||||
notificationManager.setPlayer(player);
|
||||
}
|
||||
|
||||
private final class DescriptionAdapter implements PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
|
||||
private RecipientId cachedRecipientId;
|
||||
private Bitmap cachedBitmap;
|
||||
|
||||
@Override
|
||||
public String getCurrentContentTitle(Player player) {
|
||||
if (hasMetadata()) {
|
||||
return Objects.toString(controller.getMetadata().getDescription().getTitle(), null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable PendingIntent createCurrentContentIntent(Player player) {
|
||||
if (!hasMetadata()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
|
||||
if (serializedRecipientId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
RecipientId recipientId = RecipientId.from(serializedRecipientId);
|
||||
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION);
|
||||
long threadId = controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID);
|
||||
|
||||
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR);
|
||||
|
||||
if (color == 0) {
|
||||
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor();
|
||||
}
|
||||
|
||||
notificationManager.setColor(color);
|
||||
|
||||
Intent conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
|
||||
.withStartingPosition(startingPosition)
|
||||
.build();
|
||||
|
||||
conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
return PendingIntent.getActivity(context,
|
||||
0,
|
||||
conversationActivity,
|
||||
PendingIntentFlags.cancelCurrent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentContentText(Player player) {
|
||||
if (hasMetadata()) {
|
||||
return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) {
|
||||
if (!hasMetadata() || !SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
|
||||
cachedBitmap = null;
|
||||
cachedRecipientId = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID);
|
||||
if (serializedRecipientId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
RecipientId currentRecipientId = RecipientId.from(serializedRecipientId);
|
||||
|
||||
if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) {
|
||||
return cachedBitmap;
|
||||
} else {
|
||||
cachedRecipientId = currentRecipientId;
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try {
|
||||
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId));
|
||||
callback.onBitmap(cachedBitmap);
|
||||
} catch (Exception e) {
|
||||
cachedBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMetadata() {
|
||||
return controller.getMetadata() != null && controller.getMetadata().getDescription() != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.PlaybackParameters
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class VoiceNotePlaybackController(
|
||||
private val player: ExoPlayer,
|
||||
private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters
|
||||
) : MediaSessionConnector.CommandReceiver {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
|
||||
}
|
||||
|
||||
override fun onCommand(p: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
|
||||
Log.d(TAG, "[onCommand] Received player command $command (extras? ${extras != null})")
|
||||
|
||||
if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) {
|
||||
val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f
|
||||
player.playbackParameters = PlaybackParameters(speed)
|
||||
voiceNotePlaybackParameters.setSpeed(speed)
|
||||
return true
|
||||
} else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) {
|
||||
val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC
|
||||
|
||||
val currentStreamType = Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
|
||||
if (newStreamType != currentStreamType) {
|
||||
val attributes = when (newStreamType) {
|
||||
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
|
||||
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
player.playWhenReady = false
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
public final class VoiceNotePlaybackParameters {
|
||||
|
||||
private final MediaSessionCompat mediaSessionCompat;
|
||||
|
||||
VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) {
|
||||
this.mediaSessionCompat = mediaSessionCompat;
|
||||
}
|
||||
|
||||
@NonNull PlaybackParameters getParameters() {
|
||||
float speed = getSpeed();
|
||||
return new PlaybackParameters(speed);
|
||||
}
|
||||
|
||||
void setSpeed(float speed) {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed);
|
||||
|
||||
mediaSessionCompat.setExtras(extras);
|
||||
}
|
||||
|
||||
private float getSpeed() {
|
||||
Bundle extras = mediaSessionCompat.getController().getExtras();
|
||||
|
||||
if (extras == null) {
|
||||
return 1f;
|
||||
} else {
|
||||
return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.MediaMetadata;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
|
||||
*/
|
||||
final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackPreparer {
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class);
|
||||
private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final long LIMIT = 5;
|
||||
|
||||
private final Context context;
|
||||
private final Player player;
|
||||
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
|
||||
private boolean canLoadMore;
|
||||
private Uri latestUri = Uri.EMPTY;
|
||||
|
||||
VoiceNotePlaybackPreparer(@NonNull Context context,
|
||||
@NonNull Player player,
|
||||
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
|
||||
{
|
||||
this.context = context;
|
||||
this.player = player;
|
||||
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSupportedPrepareActions() {
|
||||
return PlaybackStateCompat.ACTION_PLAY_FROM_URI;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepare(boolean playWhenReady) {
|
||||
Log.w(TAG, "Requested playback from IDLE state. Ignoring.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromMediaId(@NonNull String mediaId, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromSearch(@NonNull String query, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareFromUri(@NonNull Uri uri, boolean playWhenReady, @Nullable Bundle extras) {
|
||||
Log.d(TAG, "onPrepareFromUri: " + uri);
|
||||
if (extras == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
|
||||
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
|
||||
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
|
||||
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
|
||||
|
||||
canLoadMore = false;
|
||||
latestUri = uri;
|
||||
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> {
|
||||
if (singlePlayback) {
|
||||
if (messageId != -1) {
|
||||
return loadMediaItemsForSinglePlayback(messageId);
|
||||
} else {
|
||||
return loadMediaItemsForDraftPlayback(threadId, uri);
|
||||
}
|
||||
} else {
|
||||
return loadMediaItemsForConsecutivePlayback(messageId);
|
||||
}
|
||||
},
|
||||
mediaItems -> {
|
||||
player.clearMediaItems();
|
||||
|
||||
if (Util.hasItems(mediaItems) && Objects.equals(latestUri, uri)) {
|
||||
applyDescriptionsToQueue(mediaItems);
|
||||
|
||||
int window = Math.max(0, indexOfPlayerMediaItemByUri(uri));
|
||||
|
||||
player.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(@NonNull Timeline timeline, int reason) {
|
||||
if (timeline.getWindowCount() >= window) {
|
||||
player.setPlayWhenReady(false);
|
||||
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
|
||||
player.seekTo(window, (long) (player.getDuration() * progress));
|
||||
player.setPlayWhenReady(true);
|
||||
player.removeListener(this);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.prepare();
|
||||
canLoadMore = !singlePlayback;
|
||||
} else if (Objects.equals(latestUri, uri)) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.");
|
||||
ThreadUtil.postToMain(() -> {
|
||||
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private void applyDescriptionsToQueue(@NonNull List<MediaItem> mediaItems) {
|
||||
for (MediaItem mediaItem : mediaItems) {
|
||||
final MediaItem.LocalConfiguration playbackProperties = mediaItem.playbackProperties;
|
||||
if (playbackProperties == null) {
|
||||
continue;
|
||||
}
|
||||
int holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri);
|
||||
MediaItem next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem);
|
||||
int currentIndex = player.getCurrentWindowIndex();
|
||||
|
||||
if (holderIndex != -1) {
|
||||
if (currentIndex != holderIndex) {
|
||||
player.removeMediaItem(holderIndex);
|
||||
player.addMediaItem(holderIndex, mediaItem);
|
||||
}
|
||||
|
||||
if (currentIndex != holderIndex + 1) {
|
||||
if (player.getMediaItemCount() > 1) {
|
||||
player.removeMediaItem(holderIndex + 1);
|
||||
}
|
||||
|
||||
player.addMediaItem(holderIndex + 1, next);
|
||||
}
|
||||
} else {
|
||||
int insertLocation = indexAfter(mediaItem);
|
||||
|
||||
player.addMediaItem(insertLocation, next);
|
||||
player.addMediaItem(insertLocation, mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
int itemsCount = player.getMediaItemCount();
|
||||
if (itemsCount > 0) {
|
||||
int lastIndex = itemsCount - 1;
|
||||
MediaItem last = player.getMediaItemAt(lastIndex);
|
||||
|
||||
if (last.playbackProperties != null &&
|
||||
Objects.equals(last.playbackProperties.uri, VoiceNoteMediaItemFactory.NEXT_URI))
|
||||
{
|
||||
player.removeMediaItem(lastIndex);
|
||||
|
||||
if (player.getMediaItemCount() > 1) {
|
||||
MediaItem end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last);
|
||||
|
||||
player.addMediaItem(lastIndex, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int indexOfPlayerMediaItemByUri(@NonNull Uri uri) {
|
||||
for (int i = 0; i < player.getMediaItemCount(); i++) {
|
||||
final MediaItem.LocalConfiguration playbackProperties = player.getMediaItemAt(i).playbackProperties;
|
||||
if (playbackProperties != null && playbackProperties.uri.equals(uri)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int indexAfter(@NonNull MediaItem target) {
|
||||
int size = player.getMediaItemCount();
|
||||
long targetMessageId = target.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
for (int i = 0; i < size; i++) {
|
||||
MediaMetadata mediaMetadata = player.getMediaItemAt(i).mediaMetadata;
|
||||
long messageId = mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
|
||||
if (messageId > targetMessageId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
public void loadMoreVoiceNotes() {
|
||||
if (!canLoadMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaItem currentMediaItem = player.getCurrentMediaItem();
|
||||
if (currentMediaItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long messageId = currentMediaItem.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
|
||||
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> loadMediaItemsForConsecutivePlayback(messageId),
|
||||
mediaItems -> {
|
||||
if (Util.hasItems(mediaItems) && canLoadMore) {
|
||||
applyDescriptionsToQueue(mediaItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull List<MediaItem> loadMediaItemsForSinglePlayback(long messageId) {
|
||||
try {
|
||||
MessageRecord messageRecord = SignalDatabase.messages()
|
||||
.getMessageRecord(messageId);
|
||||
|
||||
if (!MessageRecordUtil.hasAudio(messageRecord)) {
|
||||
Log.w(TAG, "Message does not contain audio.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
MediaItem mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord);
|
||||
if (mediaItem == null) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return Collections.singletonList(mediaItem);
|
||||
}
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<MediaItem> loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) {
|
||||
return Collections
|
||||
.singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaItem> loadMediaItemsForConsecutivePlayback(long messageId) {
|
||||
try {
|
||||
List<MessageRecord> recordsAfter = SignalDatabase.messages().getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
|
||||
|
||||
return buildFilteredMessageRecordList(recordsAfter).stream()
|
||||
.map(record -> VoiceNoteMediaItemFactory
|
||||
.buildMediaItem(context, record))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull List<MessageRecord> buildFilteredMessageRecordList(@NonNull List<MessageRecord> recordsAfter) {
|
||||
return Stream.of(recordsAfter)
|
||||
.takeWhile(MessageRecordUtil::hasAudio)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public boolean onCommand(@NonNull Player player,
|
||||
@NonNull String command,
|
||||
@Nullable Bundle extras,
|
||||
@Nullable ResultReceiver cb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,36 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Process;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.AudioAttributes;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.PlaybackParameters;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.MediaSession;
|
||||
import androidx.media3.session.MediaSessionService;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -33,10 +38,8 @@ import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
|
||||
import org.thoughtcrime.securesms.jobs.UnableToStartException;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
|
||||
@@ -46,95 +49,69 @@ import java.util.List;
|
||||
/**
|
||||
* Android Service responsible for playback of voice notes.
|
||||
*/
|
||||
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
|
||||
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
|
||||
public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
|
||||
private static final String EMPTY_ROOT_ID = "empty-root-id";
|
||||
private static final String SESSION_ID = "VoiceNotePlayback";
|
||||
private static final int LOAD_MORE_THRESHOLD = 2;
|
||||
|
||||
private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY |
|
||||
PlaybackStateCompat.ACTION_PAUSE |
|
||||
PlaybackStateCompat.ACTION_SEEK_TO |
|
||||
PlaybackStateCompat.ACTION_STOP |
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE;
|
||||
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector mediaSessionConnector;
|
||||
private VoiceNotePlayer player;
|
||||
private BecomingNoisyReceiver becomingNoisyReceiver;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNoteNotificationManager voiceNoteNotificationManager;
|
||||
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
|
||||
private boolean isForegroundService;
|
||||
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
private MediaSession mediaSession;
|
||||
private VoiceNotePlayer player;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNotePlayerCallback voiceNotePlayerCallback;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
mediaSession = new MediaSessionCompat(this, TAG);
|
||||
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
|
||||
mediaSessionConnector = new MediaSessionConnector(mediaSession);
|
||||
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
|
||||
player = new VoiceNotePlayer(this);
|
||||
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
|
||||
mediaSession.getSessionToken(),
|
||||
new VoiceNoteNotificationManagerListener());
|
||||
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, voiceNotePlaybackParameters);
|
||||
|
||||
player = new VoiceNotePlayer(this);
|
||||
player.addListener(new VoiceNotePlayerEventListener());
|
||||
|
||||
mediaSessionConnector.setPlayer(player);
|
||||
mediaSessionConnector.setEnabledPlaybackActions(SUPPORTED_ACTIONS);
|
||||
mediaSessionConnector.setPlaybackPreparer(voiceNotePlaybackPreparer);
|
||||
mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession));
|
||||
voiceNotePlayerCallback = new VoiceNotePlayerCallback(this, player);
|
||||
mediaSession = buildMediaSession(false);
|
||||
|
||||
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
|
||||
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
|
||||
if (mediaSession == null) {
|
||||
Log.e(TAG, "Unable to create media session at all, stopping service to avoid crash.");
|
||||
stopSelf();
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken());
|
||||
|
||||
mediaSession.setActive(true);
|
||||
keyClearedReceiver.register();
|
||||
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
|
||||
setListener(new MediaSessionServiceListener());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(Intent rootIntent) {
|
||||
super.onTaskRemoved(rootIntent);
|
||||
|
||||
player.stop();
|
||||
player.clearMediaItems();
|
||||
mediaSession.getPlayer().stop();
|
||||
mediaSession.getPlayer().clearMediaItems();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
mediaSession.setActive(false);
|
||||
mediaSession.release();
|
||||
becomingNoisyReceiver.unregister();
|
||||
keyClearedReceiver.unregister();
|
||||
player.release();
|
||||
mediaSession.release();
|
||||
mediaSession = null;
|
||||
clearListener();
|
||||
mediaSession = null;
|
||||
super.onDestroy();
|
||||
keyClearedReceiver.unregister();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
|
||||
if (clientUid == Process.myUid()) {
|
||||
return new BrowserRoot(EMPTY_ROOT_ID, null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
result.sendResult(Collections.emptyList());
|
||||
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
|
||||
return mediaSession;
|
||||
}
|
||||
|
||||
private class VoiceNotePlayerEventListener implements Player.Listener {
|
||||
private int previousPlaybackState = player.getPlaybackState();
|
||||
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
|
||||
@@ -147,30 +124,31 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
|
||||
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
|
||||
Log.d(TAG, "playWhenReady: " + playWhenReady + "\nplaybackState: " + playbackState);
|
||||
switch (playbackState) {
|
||||
case Player.STATE_BUFFERING:
|
||||
case Player.STATE_READY:
|
||||
voiceNoteNotificationManager.showNotification(player);
|
||||
|
||||
if (!playWhenReady) {
|
||||
stopForeground(false);
|
||||
isForegroundService = false;
|
||||
becomingNoisyReceiver.unregister();
|
||||
} else {
|
||||
sendViewedReceiptForCurrentWindowIndex();
|
||||
becomingNoisyReceiver.register();
|
||||
}
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
if (previousPlaybackState == Player.STATE_READY) {
|
||||
player.clearMediaItems();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
becomingNoisyReceiver.unregister();
|
||||
voiceNoteNotificationManager.hideNotification();
|
||||
}
|
||||
previousPlaybackState = playbackState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, int reason) {
|
||||
int currentWindowIndex = newPosition.windowIndex;
|
||||
if (currentWindowIndex == C.INDEX_UNSET) {
|
||||
int mediaItemIndex = newPosition.mediaItemIndex;
|
||||
if (mediaItemIndex == C.INDEX_UNSET) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,7 +159,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + currentMediaItem.playbackProperties.uri);
|
||||
}
|
||||
|
||||
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex);
|
||||
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(mediaItemIndex);
|
||||
|
||||
final float speed = playbackParameters != null ? playbackParameters.speed : 1f;
|
||||
if (speed != player.getPlaybackParameters().speed) {
|
||||
@@ -189,16 +167,17 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
if (playbackParameters != null) {
|
||||
player.setPlaybackParameters(playbackParameters);
|
||||
}
|
||||
player.seekTo(currentWindowIndex, 1);
|
||||
player.seekTo(mediaItemIndex, 1);
|
||||
player.setPlayWhenReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD ||
|
||||
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
|
||||
|
||||
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
|
||||
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
|
||||
boolean isWithinThreshold = mediaItemIndex < LOAD_MORE_THRESHOLD ||
|
||||
mediaItemIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
|
||||
|
||||
if (isWithinThreshold && mediaItemIndex % 2 == 0) {
|
||||
voiceNotePlayerCallback.loadMoreVoiceNotes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,13 +196,60 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
|
||||
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
|
||||
mediaSession.setPlaybackToLocal(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some devices, such as the ASUS Zenfone 8, erroneously report multiple broadcast receivers for {@value Intent#ACTION_MEDIA_BUTTON} in the package manager.
|
||||
* This triggers a failure within the {@link MediaSession} initialization and throws an {@link IllegalStateException}.
|
||||
* This method will catch that exception and attempt to disable the duplicated broadcast receiver in the hopes of getting the package manager to
|
||||
* report only 1, avoiding the error.
|
||||
* If that doesn't work, it returns null, signaling the {@link MediaSession} cannot be built on this device.
|
||||
*
|
||||
* @return the built MediaSession, or null if the session cannot be built.
|
||||
*/
|
||||
private @Nullable MediaSession buildMediaSession(boolean isRetry) {
|
||||
try {
|
||||
return new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build();
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
if (isRetry) {
|
||||
Log.e(TAG, "Unable to create media session, even after retry.", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.w(TAG, "Unable to create media session with default parameters.", e);
|
||||
PackageManager pm = this.getPackageManager();
|
||||
Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
|
||||
queryIntent.setPackage(this.getPackageName());
|
||||
final List<ResolveInfo> mediaButtonReceivers = pm.queryBroadcastReceivers(queryIntent, /* flags= */ 0);
|
||||
|
||||
Log.d(TAG, "Found " + mediaButtonReceivers.size() + " BroadcastReceivers for " + Intent.ACTION_MEDIA_BUTTON);
|
||||
|
||||
boolean found = false;
|
||||
|
||||
if (mediaButtonReceivers.size() > 1) {
|
||||
for (ResolveInfo receiverInfo : mediaButtonReceivers) {
|
||||
|
||||
final ActivityInfo activityInfo = receiverInfo.activityInfo;
|
||||
|
||||
if (!found && activityInfo.packageName.contains("androidx.media.session")) {
|
||||
found = true;
|
||||
} else {
|
||||
pm.setComponentEnabledSetting(new ComponentName(activityInfo.packageName, activityInfo.name), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
|
||||
}
|
||||
}
|
||||
|
||||
return buildMediaSession(true);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
|
||||
if (isAudioMessage(currentWindowIndex)) {
|
||||
return voiceNotePlaybackParameters.getParameters();
|
||||
return player.getPlaybackParameters();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -271,49 +297,49 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
}
|
||||
|
||||
private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener {
|
||||
|
||||
@Override
|
||||
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
|
||||
if (ongoing && !isForegroundService) {
|
||||
try {
|
||||
ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
|
||||
startForeground(notificationId, notification);
|
||||
isForegroundService = true;
|
||||
} catch (UnableToStartException e) {
|
||||
Log.e(TAG, "Unable to start foreground service!", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotificationCancelled(int notificationId, boolean dismissedByUser) {
|
||||
stopForeground(true);
|
||||
isForegroundService = false;
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
|
||||
* This registers itself as a receiver on the [Context] as soon as it can.
|
||||
*/
|
||||
private static class KeyClearedReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = Log.tag(KeyClearedReceiver.class);
|
||||
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
private final Context context;
|
||||
private final ListenableFuture<MediaController> controllerFuture;
|
||||
private MediaController controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
|
||||
this.context = context;
|
||||
this.controller = new MediaControllerCompat(context, token);
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
|
||||
this.context = context;
|
||||
Log.d(TAG, "Creating media controller…");
|
||||
controllerFuture = new MediaController.Builder(context, token).buildAsync();
|
||||
Futures.addCallback(controllerFuture, new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable MediaController result) {
|
||||
Log.d(TAG, "Successfully created media controller.");
|
||||
controller = result;
|
||||
register();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context));
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (controller == null) {
|
||||
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
context.registerReceiver(this, KEY_CLEARED_FILTER);
|
||||
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
registered = true;
|
||||
Log.d(TAG, "Successfully registered.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,48 +348,24 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
MediaController.releaseFuture(controllerFuture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
controller.getTransportControls().stop();
|
||||
if (controller == null) {
|
||||
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
|
||||
} else {
|
||||
Log.i(TAG, "Received broadcast, stopping playback.");
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to pause playback when things become noisy.
|
||||
*/
|
||||
private static class BecomingNoisyReceiver extends BroadcastReceiver {
|
||||
private static final IntentFilter NOISY_INTENT_FILTER = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) {
|
||||
this.context = context;
|
||||
this.controller = new MediaControllerCompat(context, token);
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (!registered) {
|
||||
context.registerReceiver(this, NOISY_INTENT_FILTER);
|
||||
registered = true;
|
||||
}
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (registered) {
|
||||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void onReceive(Context context, @NonNull Intent intent) {
|
||||
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
|
||||
controller.getTransportControls().pause();
|
||||
}
|
||||
private static class MediaSessionServiceListener implements Listener {
|
||||
@Override
|
||||
public void onForegroundServiceStartNotAllowedException() {
|
||||
Log.e(TAG, "Could not start VoiceNotePlaybackService, encountered a ForegroundServiceStartNotAllowedException.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.DefaultLoadControl
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.ForwardingPlayer
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.audio.AudioSink
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
|
||||
|
||||
/**
|
||||
* A lightweight wrapper around ExoPlayer that compartmentalizes some logic and adds a few functions, most importantly the seek behavior.
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoiceNotePlayer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
private val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setRenderersFactory(WorkaroundRenderersFactory(context))
|
||||
.setMediaSourceFactory(SignalMediaSourceFactory(context))
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)
|
||||
.build()
|
||||
).build().apply {
|
||||
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
|
||||
}
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true).build()
|
||||
) : ForwardingPlayer(internalPlayer) {
|
||||
|
||||
init {
|
||||
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required to expose this because this is unique to [ExoPlayer], not the generic [androidx.media3.common.Player] interface.
|
||||
*/
|
||||
fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {
|
||||
internalPlayer.setAudioAttributes(audioAttributes, handleAudioFocus)
|
||||
}
|
||||
|
||||
override fun seekTo(windowIndex: Int, positionMs: Long) {
|
||||
super.seekTo(windowIndex, positionMs)
|
||||
|
||||
@@ -46,6 +64,7 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
||||
/**
|
||||
* @see RetryableInitAudioSink
|
||||
*/
|
||||
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class WorkaroundRenderersFactory(val context: Context) : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean): AudioSink? {
|
||||
return RetryableInitAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams, enableOffload)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.LocalConfiguration
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionCommands
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.util.hasAudio
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* See [VoiceNotePlaybackService].
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer) : MediaSession.Callback {
|
||||
companion object {
|
||||
private val SUPPORTED_ACTIONS = Player.Commands.Builder()
|
||||
.addAll(
|
||||
Player.COMMAND_PLAY_PAUSE,
|
||||
Player.COMMAND_PREPARE,
|
||||
Player.COMMAND_STOP,
|
||||
Player.COMMAND_SEEK_TO_DEFAULT_POSITION,
|
||||
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_BACK,
|
||||
Player.COMMAND_SEEK_FORWARD,
|
||||
Player.COMMAND_SET_SPEED_AND_PITCH,
|
||||
Player.COMMAND_SET_REPEAT_MODE,
|
||||
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_GET_TIMELINE,
|
||||
Player.COMMAND_GET_METADATA,
|
||||
Player.COMMAND_SET_PLAYLIST_METADATA,
|
||||
Player.COMMAND_SET_MEDIA_ITEM,
|
||||
Player.COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
Player.COMMAND_GET_AUDIO_ATTRIBUTES,
|
||||
Player.COMMAND_GET_TEXT,
|
||||
Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS,
|
||||
Player.COMMAND_RELEASE
|
||||
)
|
||||
.build()
|
||||
|
||||
private val CUSTOM_COMMANDS = SessionCommands.Builder()
|
||||
.add(SessionCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, Bundle.EMPTY))
|
||||
.add(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY))
|
||||
.build()
|
||||
private const val DEFAULT_PLAYBACK_SPEED = 1f
|
||||
private const val LIMIT: Long = 5
|
||||
}
|
||||
|
||||
private val TAG = Log.tag(VoiceNotePlayerCallback::class.java)
|
||||
private val EXECUTOR: Executor = Executors.newSingleThreadExecutor()
|
||||
private val customLayout: List<CommandButton> = mutableListOf<CommandButton>().apply {
|
||||
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_PLAY_PAUSE).build())
|
||||
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_STOP).build())
|
||||
}
|
||||
private var canLoadMore = false
|
||||
private var latestUri = Uri.EMPTY
|
||||
|
||||
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
|
||||
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
|
||||
}
|
||||
|
||||
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
||||
if (customLayout.isNotEmpty() && controller.controllerVersion != 0) {
|
||||
session.setCustomLayout(controller, customLayout)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>): ListenableFuture<MutableList<MediaItem>> {
|
||||
mediaItems.forEach {
|
||||
val uri = it.localConfiguration?.uri
|
||||
if (uri != null) {
|
||||
val extras = it.requestMetadata.extras
|
||||
onPrepareFromUri(uri, extras)
|
||||
} else {
|
||||
throw UnsupportedOperationException("VoiceNotePlayerCallback does not support onPrepareFromMediaId/onPrepareFromSearch")
|
||||
}
|
||||
}
|
||||
return super.onAddMediaItems(mediaSession, controller, mediaItems)
|
||||
}
|
||||
|
||||
private fun onPrepareFromUri(uri: Uri, extras: Bundle?) {
|
||||
Log.d(TAG, "onPrepareFromUri: $uri")
|
||||
if (extras == null) {
|
||||
return
|
||||
}
|
||||
val messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID)
|
||||
val threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID)
|
||||
val progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0.0)
|
||||
val singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false)
|
||||
canLoadMore = false
|
||||
latestUri = uri
|
||||
SimpleTask.run(
|
||||
EXECUTOR,
|
||||
{
|
||||
if (singlePlayback) {
|
||||
if (messageId != -1L) {
|
||||
return@run loadMediaItemsForSinglePlayback(messageId)
|
||||
} else {
|
||||
return@run loadMediaItemsForDraftPlayback(threadId, uri)
|
||||
}
|
||||
} else {
|
||||
return@run loadMediaItemsForConsecutivePlayback(messageId)
|
||||
}
|
||||
}
|
||||
) { mediaItems: List<MediaItem> ->
|
||||
player.clearMediaItems()
|
||||
if (mediaItems.isNotEmpty() && latestUri == uri) {
|
||||
addItemsToPlaylist(mediaItems)
|
||||
val window = max(0, indexOfPlayerMediaItemByUri(uri))
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
if (timeline.windowCount >= window) {
|
||||
player.playWhenReady = false
|
||||
player.playbackParameters = PlaybackParameters(DEFAULT_PLAYBACK_SPEED)
|
||||
player.seekTo(window, (player.duration * progress).toLong())
|
||||
player.playWhenReady = true
|
||||
player.removeListener(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
player.prepare()
|
||||
canLoadMore = !singlePlayback
|
||||
} else if (latestUri == uri) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.")
|
||||
ThreadUtil.postToMain {
|
||||
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> {
|
||||
return when (customCommand.customAction) {
|
||||
VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED -> incrementPlaybackSpeed(args)
|
||||
VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM -> setAudioStream(args)
|
||||
else -> super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
}
|
||||
|
||||
private fun incrementPlaybackSpeed(extras: Bundle): ListenableFuture<SessionResult> {
|
||||
val speed = extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f)
|
||||
player.playbackParameters = PlaybackParameters(speed)
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
private fun setAudioStream(extras: Bundle): ListenableFuture<SessionResult> {
|
||||
val newStreamType: Int = extras.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC)
|
||||
|
||||
val currentStreamType = androidx.media3.common.util.Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
|
||||
if (newStreamType != currentStreamType) {
|
||||
val attributes = when (newStreamType) {
|
||||
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
|
||||
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
player.playWhenReady = false
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun addItemsToPlaylist(mediaItems: List<MediaItem>) {
|
||||
var mediaItemsWithNextTone = mediaItems.flatMap { listOf(it, VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(it)) }.toMutableList()
|
||||
mediaItemsWithNextTone = mediaItemsWithNextTone.subList(0, mediaItemsWithNextTone.size - 1).toMutableList()
|
||||
if (player.mediaItemCount == 0) {
|
||||
mediaItemsWithNextTone += VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(mediaItemsWithNextTone.last())
|
||||
player.addMediaItems(mediaItemsWithNextTone)
|
||||
} else {
|
||||
player.addMediaItems(player.mediaItemCount, mediaItemsWithNextTone)
|
||||
}
|
||||
}
|
||||
|
||||
private fun indexOfPlayerMediaItemByUri(uri: Uri): Int {
|
||||
for (i in 0 until player.mediaItemCount) {
|
||||
val playbackProperties: LocalConfiguration? = player.getMediaItemAt(i).playbackProperties
|
||||
if (playbackProperties?.uri == uri) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fun loadMoreVoiceNotes() {
|
||||
if (!canLoadMore) {
|
||||
return
|
||||
}
|
||||
val currentMediaItem: MediaItem = player.currentMediaItem ?: return
|
||||
val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
|
||||
val currentPlaylist = List(player.mediaItemCount) { index -> player.getMediaItemAt(index) }.mapNotNull { it.requestMetadata.mediaUri }
|
||||
SimpleTask.run(
|
||||
EXECUTOR,
|
||||
{ loadMediaItemsForConsecutivePlayback(messageId).filterNot { it.requestMetadata.mediaUri in currentPlaylist } }
|
||||
) { mediaItems: List<MediaItem> ->
|
||||
if (mediaItems.isNotEmpty() && canLoadMore) {
|
||||
addItemsToPlaylist(mediaItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List<MediaItem> {
|
||||
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
listOf(messages.getMessageRecord(messageId)).messageRecordsToVoiceNoteMediaItems()
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
messages.getMessagesAfterVoiceNoteInclusive(messageId, LIMIT).messageRecordsToVoiceNoteMediaItems()
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<MessageRecord>.messageRecordsToVoiceNoteMediaItems(): List<MediaItem> {
|
||||
return this.filter { it.hasAudio() }.mapNotNull { VoiceNoteMediaItemFactory.buildMediaItem(context, it) }
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import android.hardware.SensorManager
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -25,7 +26,7 @@ private const val PROXIMITY_THRESHOLD = 5f
|
||||
*/
|
||||
class VoiceNoteProximityWakeLockManager(
|
||||
private val activity: FragmentActivity,
|
||||
private val mediaController: MediaControllerCompat
|
||||
private val mediaController: MediaController
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
|
||||
@@ -33,7 +34,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
|
||||
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
private val mediaControllerCallback = MediaControllerCallback()
|
||||
private val mediaControllerCallback = ProximityListener()
|
||||
private val hardwareSensorEventListener = HardwareSensorEventListener()
|
||||
|
||||
private var startTime: Long = -1
|
||||
@@ -46,7 +47,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
if (proximitySensor != null) {
|
||||
mediaController.registerCallback(mediaControllerCallback)
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
}
|
||||
|
||||
fun unregisterCallbacksAndRelease() {
|
||||
mediaController.unregisterCallback(mediaControllerCallback)
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
|
||||
@@ -69,9 +70,6 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING ||
|
||||
mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING
|
||||
|
||||
private fun cleanUpWakeLock() {
|
||||
startTime = -1L
|
||||
sensorManager.unregisterListener(hardwareSensorEventListener)
|
||||
@@ -87,26 +85,29 @@ class VoiceNoteProximityWakeLockManager(
|
||||
private fun sendNewStreamTypeToPlayer(newStreamType: Int) {
|
||||
val params = Bundle()
|
||||
params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType)
|
||||
mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null)
|
||||
mediaController.sendCustomCommand(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY), params)
|
||||
}
|
||||
|
||||
inner class MediaControllerCallback : MediaControllerCompat.Callback() {
|
||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlayerActive()) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
inner class ProximityListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +117,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
if (startTime == -1L ||
|
||||
System.currentTimeMillis() - startTime <= 500 ||
|
||||
!isActivityResumed() ||
|
||||
!isPlayerActive() ||
|
||||
!mediaController.isPlaying ||
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator;
|
||||
|
||||
/**
|
||||
* Navigator to help support seek forward and back.
|
||||
*/
|
||||
final class VoiceNoteQueueNavigator extends TimelineQueueNavigator {
|
||||
private static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build();
|
||||
|
||||
public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession) {
|
||||
super(mediaSession);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaDescriptionCompat getMediaDescription(@NonNull Player player, int windowIndex) {
|
||||
MediaItem mediaItem = windowIndex >= 0 && windowIndex < player.getMediaItemCount() ? player.getMediaItemAt(windowIndex) : null;
|
||||
|
||||
if (mediaItem == null || mediaItem.playbackProperties == null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
MediaDescriptionCompat mediaDescriptionCompat = (MediaDescriptionCompat) mediaItem.playbackProperties.tag;
|
||||
if (mediaDescriptionCompat == null) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return mediaDescriptionCompat;
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ArgbEvaluator;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.accessibility.AccessibilityManager;
|
||||
import android.view.animation.AccelerateInterpolator;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.AccessibilityUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public final class WebRtcAnswerDeclineButton extends LinearLayout implements AccessibilityManager.TouchExplorationStateChangeListener {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(WebRtcAnswerDeclineButton.class);
|
||||
|
||||
private static final int TOTAL_TIME = 1000;
|
||||
private static final int SHAKE_TIME = 200;
|
||||
|
||||
private static final int UP_TIME = (TOTAL_TIME - SHAKE_TIME) / 2;
|
||||
private static final int DOWN_TIME = (TOTAL_TIME - SHAKE_TIME) / 2;
|
||||
private static final int FADE_OUT_TIME = 300;
|
||||
private static final int FADE_IN_TIME = 100;
|
||||
private static final int SHIMMER_TOTAL = UP_TIME + SHAKE_TIME;
|
||||
|
||||
private static final int ANSWER_THRESHOLD = 112;
|
||||
private static final int DECLINE_THRESHOLD = 56;
|
||||
|
||||
private AnswerDeclineListener listener;
|
||||
@Nullable private DragToAnswer dragToAnswerListener;
|
||||
private AccessibilityManager accessibilityManager;
|
||||
private boolean ringAnimation;
|
||||
|
||||
public WebRtcAnswerDeclineButton(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
setOrientation(LinearLayout.VERTICAL);
|
||||
setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
accessibilityManager = ServiceUtil.getAccessibilityManager(getContext());
|
||||
|
||||
createView(accessibilityManager.isTouchExplorationEnabled());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
accessibilityManager.addTouchExplorationStateChangeListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
accessibilityManager.removeTouchExplorationStateChangeListener(this);
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
private void createView(boolean isTouchExplorationEnabled) {
|
||||
if (isTouchExplorationEnabled) {
|
||||
inflate(getContext(), R.layout.webrtc_answer_decline_button_accessible, this);
|
||||
|
||||
findViewById(R.id.answer).setOnClickListener((view) -> listener.onAnswered());
|
||||
findViewById(R.id.reject).setOnClickListener((view) -> listener.onDeclined());
|
||||
} else {
|
||||
inflate(getContext(), R.layout.webrtc_answer_decline_button, this);
|
||||
|
||||
ImageView answer = findViewById(R.id.answer);
|
||||
|
||||
dragToAnswerListener = new DragToAnswer(answer, this);
|
||||
|
||||
answer.setOnTouchListener(dragToAnswerListener);
|
||||
|
||||
if (ringAnimation) {
|
||||
startRingingAnimation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setAnswerDeclineListener(AnswerDeclineListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void startRingingAnimation() {
|
||||
ringAnimation = true;
|
||||
if (dragToAnswerListener != null) {
|
||||
dragToAnswerListener.startRingingAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
public void stopRingingAnimation() {
|
||||
ringAnimation = false;
|
||||
if (dragToAnswerListener != null) {
|
||||
dragToAnswerListener.stopRingingAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchExplorationStateChanged(boolean enabled) {
|
||||
removeAllViews();
|
||||
createView(enabled);
|
||||
}
|
||||
|
||||
private class DragToAnswer implements View.OnTouchListener {
|
||||
|
||||
private final TextView swipeUpText;
|
||||
private final ImageView answer;
|
||||
private final TextView swipeDownText;
|
||||
|
||||
private final ImageView arrowOne;
|
||||
private final ImageView arrowTwo;
|
||||
private final ImageView arrowThree;
|
||||
private final ImageView arrowFour;
|
||||
|
||||
private float lastY;
|
||||
|
||||
private boolean animating = false;
|
||||
private boolean complete = false;
|
||||
|
||||
private AnimatorSet animatorSet;
|
||||
|
||||
private DragToAnswer(@NonNull ImageView answer, WebRtcAnswerDeclineButton view) {
|
||||
this.swipeUpText = view.findViewById(R.id.swipe_up_text);
|
||||
this.answer = answer;
|
||||
this.swipeDownText = view.findViewById(R.id.swipe_down_text);
|
||||
|
||||
this.arrowOne = view.findViewById(R.id.arrow_one);
|
||||
this.arrowTwo = view.findViewById(R.id.arrow_two);
|
||||
this.arrowThree = view.findViewById(R.id.arrow_three);
|
||||
this.arrowFour = view.findViewById(R.id.arrow_four);
|
||||
}
|
||||
|
||||
private void startRingingAnimation() {
|
||||
if (!animating) {
|
||||
animating = true;
|
||||
animateElements(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopRingingAnimation() {
|
||||
if (animating) {
|
||||
animating = false;
|
||||
resetElements();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
resetElements();
|
||||
swipeUpText.animate().alpha(0).setDuration(200).start();
|
||||
swipeDownText.animate().alpha(0).setDuration(200).start();
|
||||
lastY = event.getRawY();
|
||||
break;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
case MotionEvent.ACTION_UP:
|
||||
swipeUpText.clearAnimation();
|
||||
swipeDownText.clearAnimation();
|
||||
swipeUpText.setAlpha(1);
|
||||
swipeDownText.setAlpha(1);
|
||||
answer.setRotation(0);
|
||||
answer.getDrawable().setTint(getResources().getColor(R.color.green_600));
|
||||
answer.getBackground().setTint(Color.WHITE);
|
||||
|
||||
animating = true;
|
||||
animateElements(0);
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
float difference = event.getRawY() - lastY;
|
||||
|
||||
float differenceThreshold;
|
||||
float percentageToThreshold;
|
||||
int backgroundColor;
|
||||
int foregroundColor;
|
||||
|
||||
if (difference <= 0) {
|
||||
differenceThreshold = ViewUtil.dpToPx(getContext(), ANSWER_THRESHOLD);
|
||||
percentageToThreshold = Math.min(1, (difference * -1) / differenceThreshold);
|
||||
backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.green_100), getResources().getColor(R.color.green_600));
|
||||
|
||||
if (percentageToThreshold > 0.5) {
|
||||
foregroundColor = Color.WHITE;
|
||||
} else {
|
||||
foregroundColor = getResources().getColor(R.color.green_600);
|
||||
}
|
||||
|
||||
answer.setTranslationY(difference);
|
||||
|
||||
if (percentageToThreshold == 1 && listener != null) {
|
||||
answer.setVisibility(View.INVISIBLE);
|
||||
lastY = event.getRawY();
|
||||
if (!complete) {
|
||||
complete = true;
|
||||
listener.onAnswered();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
differenceThreshold = ViewUtil.dpToPx(getContext(), DECLINE_THRESHOLD);
|
||||
percentageToThreshold = Math.min(1, difference / differenceThreshold);
|
||||
backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.red_100), getResources().getColor(R.color.red_600));
|
||||
|
||||
if (percentageToThreshold > 0.5) {
|
||||
foregroundColor = Color.WHITE;
|
||||
} else {
|
||||
foregroundColor = getResources().getColor(R.color.green_600);
|
||||
}
|
||||
|
||||
answer.setRotation(135 * percentageToThreshold);
|
||||
|
||||
if (percentageToThreshold == 1 && listener != null) {
|
||||
answer.setVisibility(View.INVISIBLE);
|
||||
lastY = event.getRawY();
|
||||
|
||||
if (!complete) {
|
||||
complete = true;
|
||||
listener.onDeclined();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
answer.getBackground().setTint(backgroundColor);
|
||||
answer.getDrawable().setTint(foregroundColor);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void animateElements(int delay) {
|
||||
if (AccessibilityUtil.areAnimationsDisabled(getContext())) return;
|
||||
|
||||
ObjectAnimator fabUp = getUpAnimation(answer);
|
||||
ObjectAnimator fabDown = getDownAnimation(answer);
|
||||
ObjectAnimator fabShake = getShakeAnimation(answer);
|
||||
|
||||
animatorSet = new AnimatorSet();
|
||||
animatorSet.play(fabUp).with(getUpAnimation(swipeUpText));
|
||||
animatorSet.play(fabShake).after(fabUp);
|
||||
animatorSet.play(fabDown).with(getDownAnimation(swipeUpText)).after(fabShake);
|
||||
|
||||
animatorSet.play(getFadeOut(swipeDownText)).with(fabUp);
|
||||
animatorSet.play(getFadeIn(swipeDownText)).after(fabDown);
|
||||
|
||||
animatorSet.play(getShimmer(arrowFour, arrowThree, arrowTwo, arrowOne));
|
||||
|
||||
animatorSet.addListener(new Animator.AnimatorListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animator animation) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
if (animating) animateElements(1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationCancel(Animator animation) {}
|
||||
@Override
|
||||
public void onAnimationRepeat(Animator animation) {}
|
||||
});
|
||||
|
||||
animatorSet.setStartDelay(delay);
|
||||
animatorSet.start();
|
||||
}
|
||||
|
||||
private void resetElements() {
|
||||
animating = false;
|
||||
complete = false;
|
||||
|
||||
if (animatorSet != null) animatorSet.cancel();
|
||||
|
||||
swipeUpText.setTranslationY(0);
|
||||
answer.setTranslationY(0);
|
||||
swipeDownText.setAlpha(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static Animator getShimmer(View... targets) {
|
||||
AnimatorSet animatorSet = new AnimatorSet();
|
||||
int evenDuration = SHIMMER_TOTAL / targets.length;
|
||||
int interval = 75;
|
||||
|
||||
for (int i=0;i<targets.length;i++) {
|
||||
animatorSet.play(getShimmer(targets[i], evenDuration + (evenDuration - interval)))
|
||||
.after(interval * i);
|
||||
}
|
||||
|
||||
return animatorSet;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getShimmer(View target, int duration) {
|
||||
ObjectAnimator shimmer = ObjectAnimator.ofFloat(target, "alpha", 0, 1, 0);
|
||||
shimmer.setDuration(duration);
|
||||
|
||||
return shimmer;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getShakeAnimation(View target) {
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationX", 0, 25, -25, 25, -25,15, -15, 6, -6, 0);
|
||||
animator.setDuration(SHAKE_TIME);
|
||||
|
||||
return animator;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getUpAnimation(View target) {
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationY", 0, -1 * ViewUtil.dpToPx(target.getContext(), 32));
|
||||
animator.setInterpolator(new AccelerateInterpolator());
|
||||
animator.setDuration(UP_TIME);
|
||||
|
||||
return animator;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getDownAnimation(View target) {
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationY", 0);
|
||||
animator.setInterpolator(new DecelerateInterpolator());
|
||||
animator.setDuration(DOWN_TIME);
|
||||
|
||||
return animator;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getFadeOut(View target) {
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "alpha", 1, 0);
|
||||
animator.setDuration(FADE_OUT_TIME);
|
||||
return animator;
|
||||
}
|
||||
|
||||
private static ObjectAnimator getFadeIn(View target) {
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "alpha", 0, 1);
|
||||
animator.setDuration(FADE_IN_TIME);
|
||||
return animator;
|
||||
}
|
||||
|
||||
public interface AnswerDeclineListener {
|
||||
void onAnswered();
|
||||
void onDeclined();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.graphics.ColorMatrixColorFilter;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.os.Looper;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -25,6 +26,7 @@ import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.core.util.Preconditions;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
@@ -43,6 +45,7 @@ import com.google.common.collect.Sets;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation;
|
||||
@@ -71,6 +74,8 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import kotlin.concurrent.ThreadsKt;
|
||||
|
||||
public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
private static final String TAG = Log.tag(WebRtcCallView.class);
|
||||
@@ -593,6 +598,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
public void setStatus(@Nullable String status) {
|
||||
ThreadUtil.assertMainThread();
|
||||
this.status.setText(status);
|
||||
collapsedToolbar.setSubtitle(status);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
return new InCallStatus.JoinedCallLinkUsers((int) participantsState.getParticipantCount().orElse(0));
|
||||
}
|
||||
}
|
||||
).distinctUntilChanged();
|
||||
).distinctUntilChanged().observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
public Observable<CallParticipantsState> getCallParticipantsState() {
|
||||
@@ -190,7 +190,7 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
public Observable<Boolean> shouldShowSpeakerHint() {
|
||||
return shouldShowSpeakerHint;
|
||||
return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
public WebRtcAudioOutput getCurrentAudioOutput() {
|
||||
|
||||
@@ -57,7 +57,7 @@ public class TurnOffContactJoinedNotificationsActivity extends AppCompatActivity
|
||||
ThreadTable threadTable = SignalDatabase.threads();
|
||||
|
||||
List<MessageTable.MarkedMessageInfo> marked = threadTable.setRead(getIntent().getLongExtra(EXTRA_THREAD_ID, -1), false);
|
||||
MarkReadReceiver.process(this, marked);
|
||||
MarkReadReceiver.process(marked);
|
||||
|
||||
SignalStore.settings().setNotifyWhenContactJoinsSignal(false);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(this);
|
||||
|
||||
@@ -37,7 +37,7 @@ import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagingController;
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ConversationAdapterBridge {
|
||||
const val PAYLOAD_TIMESTAMP = 0
|
||||
const val PAYLOAD_NAME_COLORS = 1
|
||||
const val PAYLOAD_SELECTED = 2
|
||||
const val PAYLOAD_PARENT_SCROLLING = 3
|
||||
}
|
||||
|
||||
fun hasNoConversationMessages(): Boolean
|
||||
|
||||
@@ -57,9 +57,9 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.common.collect.Sets;
|
||||
|
||||
@@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.ui.payment.PaymentMessageView;
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationBodyUtil;
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.MediaTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
@@ -135,6 +135,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
@@ -240,6 +241,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final ProgressWheelClickListener progressWheelClickListener = new ProgressWheelClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
@@ -261,7 +263,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private boolean hasWallpaper;
|
||||
private float lastYDownRelativeToThis;
|
||||
private ProjectionList colorizerProjections = new ProjectionList(3);
|
||||
private boolean isBound = false;
|
||||
private boolean isBound = false;
|
||||
|
||||
private final Runnable shrinkBubble = new Runnable() {
|
||||
@Override
|
||||
@@ -416,6 +418,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.conversationRecipient.observeForever(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setParentScrolling(boolean isParentScrolling) {
|
||||
bodyBubble.setParentScrolling(isParentScrolling);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSelectedState() {
|
||||
setHasBeenQuoted(conversationMessage);
|
||||
@@ -554,22 +561,22 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
int minSize = Math.min(maxBubbleWidth, Math.max(bodyText.getMeasuredWidth() + ViewUtil.dpToPx(6) + footerWidth + bodyMargins, bodyBubble.getMeasuredWidth()));
|
||||
|
||||
if (hasQuote(messageRecord) && sizeWithMargins < availableWidth) {
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
|
||||
needsMeasure = true;
|
||||
updatingFooter = true;
|
||||
} else if (sizeWithMargins != bodyText.getMeasuredWidth() && sizeWithMargins <= minSize) {
|
||||
bodyBubble.getLayoutParams().width = minSize;
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
|
||||
needsMeasure = true;
|
||||
updatingFooter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatingFooter && !messageRecord.isFailed() && bodyText.getLastLineWidth() + ViewUtil.dpToPx(6) + footerWidth <= bodyText.getMeasuredWidth()) {
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
|
||||
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
|
||||
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
|
||||
updatingFooter = true;
|
||||
needsMeasure = true;
|
||||
}
|
||||
@@ -577,8 +584,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
int defaultTopMarginForRecord = getDefaultTopMarginForRecord(messageRecord, defaultTopMargin, defaultBottomMargin);
|
||||
if (!updatingFooter && ViewUtil.getTopMargin(footer) != defaultTopMarginForRecord) {
|
||||
ViewUtil.setTopMargin(footer, defaultTopMarginForRecord);
|
||||
ViewUtil.setBottomMargin(footer, defaultBottomMargin);
|
||||
ViewUtil.setTopMargin(footer, defaultTopMarginForRecord, false);
|
||||
ViewUtil.setBottomMargin(footer, defaultBottomMargin, false);
|
||||
needsMeasure = true;
|
||||
}
|
||||
|
||||
@@ -1095,6 +1102,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (callToActionStub.resolved()) callToActionStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
revealableStub.get().setMessage((MmsMessageRecord) messageRecord, hasWallpaper);
|
||||
@@ -1113,6 +1121,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
|
||||
@@ -1134,6 +1143,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
@@ -1159,6 +1169,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(linkPreview.getThumbnail().get())), showControls, false);
|
||||
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
|
||||
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
|
||||
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
|
||||
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false);
|
||||
@@ -1195,6 +1206,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||
@@ -1222,6 +1234,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
@@ -1250,6 +1263,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
if (hasSticker(messageRecord)) {
|
||||
@@ -1281,6 +1295,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
|
||||
@@ -1294,6 +1309,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
false);
|
||||
mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener());
|
||||
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
|
||||
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
|
||||
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
|
||||
mediaThumbnailStub.require().setOnClickListener(passthroughClickListener);
|
||||
mediaThumbnailStub.require().showShade(messageRecord.isDisplayBodyEmpty(getContext()) && !hasExtraText(messageRecord));
|
||||
@@ -1335,6 +1351,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
|
||||
@@ -1351,6 +1368,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
|
||||
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
|
||||
|
||||
@@ -1367,6 +1385,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
@@ -1515,7 +1534,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private void linkifyMessageBody(@NonNull Spannable messageBody,
|
||||
boolean shouldLinkifyAllLinks)
|
||||
{
|
||||
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
|
||||
V2ConversationItemUtils.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
|
||||
|
||||
if (conversationMessage.hasStyleLinks()) {
|
||||
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {
|
||||
@@ -1535,6 +1554,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setStatusIcons(MessageRecord messageRecord, boolean hasWallpaper) {
|
||||
bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0);
|
||||
|
||||
@@ -1611,17 +1631,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
if (!isFooterVisible(current, next, isGroupThread) && isStoryReaction(current)) {
|
||||
ViewUtil.setBottomMargin(quoteView, (int) DimensionUnit.DP.toPixels(8));
|
||||
ViewUtil.setBottomMargin(quoteView, (int) DimensionUnit.DP.toPixels(8), false);
|
||||
} else {
|
||||
ViewUtil.setBottomMargin(quoteView, 0);
|
||||
ViewUtil.setBottomMargin(quoteView, 0, false);
|
||||
}
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.require(), readDimen(R.dimen.message_bubble_top_padding));
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.require(), readDimen(R.dimen.message_bubble_top_padding), false);
|
||||
}
|
||||
|
||||
if (linkPreviewStub.resolved() && !hasBigImageLinkPreview(current)) {
|
||||
ViewUtil.setTopMargin(linkPreviewStub.get(), readDimen(R.dimen.message_bubble_top_padding));
|
||||
ViewUtil.setTopMargin(linkPreviewStub.get(), readDimen(R.dimen.message_bubble_top_padding), false);
|
||||
}
|
||||
} else {
|
||||
if (quoteView != null) {
|
||||
@@ -1630,7 +1650,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
int topMargin = (current.isOutgoing() || !startOfCluster || !groupThread) ? 0 : readDimen(R.dimen.message_bubble_top_image_margin);
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.require(), topMargin);
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.require(), topMargin, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1661,7 +1681,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private void setReactionsWithWidth(@NonNull MessageRecord current, int width) {
|
||||
reactionsView.setReactions(current.getReactions(), width);
|
||||
reactionsView.setReactions(current.getReactions());
|
||||
reactionsView.setBubbleWidth(width);
|
||||
reactionsView.setOnClickListener(v -> {
|
||||
if (eventListener == null) return;
|
||||
|
||||
@@ -2293,6 +2314,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
return bodyBubble;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateChatColorsDrawable(@NonNull ViewGroup coordinateRoot) {
|
||||
// Intentionally left blank.
|
||||
}
|
||||
|
||||
@Override public @Nullable SnapshotStrategy getSnapshotStrategy() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private class SharedContactEventListener implements SharedContactView.EventListener {
|
||||
@Override
|
||||
public void onAddToContactsClicked(@NonNull Contact contact) {
|
||||
@@ -2410,6 +2440,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressWheelClickListener implements SlideClickListener {
|
||||
|
||||
@Override
|
||||
public void onClick(View v, Slide slide) {
|
||||
final boolean isIncremental = slide.asAttachment().getIncrementalDigest() != null;
|
||||
final boolean contentTypeSupported = MediaUtil.isVideoType(slide.getContentType());
|
||||
if (FeatureFlags.instantVideoPlayback() && isIncremental && contentTypeSupported) {
|
||||
launchMediaPreview(v, slide);
|
||||
} else {
|
||||
Log.d(TAG, "Non-eligible slide clicked: " + "\tisIncremental: " + isIncremental + "\tcontentTypeSupported: " + contentTypeSupported);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SlideClickPassthroughListener implements SlideClickListener {
|
||||
|
||||
private final SlidesClickedListener original;
|
||||
@@ -2443,34 +2487,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
} else if (!canPlayContent && mediaItem != null && eventListener != null) {
|
||||
eventListener.onPlayInlineContent(conversationMessage);
|
||||
} else if (MediaPreviewV2Fragment.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
|
||||
if (eventListener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs(
|
||||
messageRecord.getThreadId(),
|
||||
messageRecord.getTimestamp(),
|
||||
slide.getUri(),
|
||||
slide.getContentType(),
|
||||
slide.asAttachment().getSize(),
|
||||
slide.getCaption().orElse(null),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
MediaTable.Sorting.Newest,
|
||||
slide.isVideoGif(),
|
||||
new MediaIntentFactory.SharedElementArgs(
|
||||
slide.asAttachment().getWidth(),
|
||||
slide.asAttachment().getHeight(),
|
||||
mediaThumbnailStub.require().getCorners().getTopLeft(),
|
||||
mediaThumbnailStub.require().getCorners().getTopRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomLeft()
|
||||
),
|
||||
false);
|
||||
MediaPreviewCache.INSTANCE.setDrawable(((ThumbnailView) v).getImageDrawable());
|
||||
eventListener.goToMediaPreview(ConversationItem.this, v, args);
|
||||
launchMediaPreview(v, slide);
|
||||
} else if (slide.getUri() != null) {
|
||||
Log.i(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType());
|
||||
Uri publicUri = PartAuthority.getAttachmentPublicUri(slide.getUri());
|
||||
@@ -2507,6 +2524,47 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void launchMediaPreview(View v, Slide slide) {
|
||||
if (eventListener == null) {
|
||||
Log.w(TAG, "Could not launch media preview for item: eventListener was null");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri mediaUri = slide.getUri();
|
||||
|
||||
if (mediaUri == null) {
|
||||
Log.w(TAG, "Could not launch media preview for item: uri was null");
|
||||
return;
|
||||
}
|
||||
|
||||
MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs(
|
||||
messageRecord.getThreadId(),
|
||||
messageRecord.getTimestamp(),
|
||||
mediaUri,
|
||||
slide.getContentType(),
|
||||
slide.asAttachment().getSize(),
|
||||
slide.getCaption().orElse(null),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
MediaTable.Sorting.Newest,
|
||||
slide.isVideoGif(),
|
||||
new MediaIntentFactory.SharedElementArgs(
|
||||
slide.asAttachment().getWidth(),
|
||||
slide.asAttachment().getHeight(),
|
||||
mediaThumbnailStub.require().getCorners().getTopLeft(),
|
||||
mediaThumbnailStub.require().getCorners().getTopRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomRight(),
|
||||
mediaThumbnailStub.require().getCorners().getBottomLeft()
|
||||
),
|
||||
false);
|
||||
if (v instanceof ThumbnailView) {
|
||||
MediaPreviewCache.INSTANCE.setDrawable(((ThumbnailView) v).getImageDrawable());
|
||||
}
|
||||
eventListener.goToMediaPreview(ConversationItem.this, v, args);
|
||||
}
|
||||
|
||||
private class PassthroughClickListener implements View.OnLongClickListener, View.OnClickListener {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.annotation.Nullable;
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -30,19 +31,33 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
private Projection quoteViewProjection;
|
||||
private Projection videoPlayerProjection;
|
||||
|
||||
private final BodyBubbleLayoutTransition bodyBubbleLayoutTransition = new BodyBubbleLayoutTransition();
|
||||
|
||||
public ConversationItemBodyBubble(Context context) {
|
||||
super(context);
|
||||
setLayoutTransition(new BodyBubbleLayoutTransition());
|
||||
init();
|
||||
}
|
||||
|
||||
public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
setLayoutTransition(new BodyBubbleLayoutTransition());
|
||||
init();
|
||||
}
|
||||
|
||||
public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
setLayoutTransition(new BodyBubbleLayoutTransition());
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
setLayoutTransition(bodyBubbleLayoutTransition);
|
||||
}
|
||||
|
||||
public void setParentScrolling(boolean isParentScrolling) {
|
||||
if (isParentScrolling) {
|
||||
setLayoutTransition(null);
|
||||
} else {
|
||||
setLayoutTransition(bodyBubbleLayoutTransition);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOutliners(@NonNull List<Outliner> outliners) {
|
||||
|
||||
@@ -43,6 +43,13 @@ object ConversationItemSelection {
|
||||
drawConversationItem: Boolean,
|
||||
hasReaction: Boolean
|
||||
): Bitmap {
|
||||
val snapshotStrategy = target.getSnapshotStrategy()
|
||||
if (snapshotStrategy != null) {
|
||||
return createBitmap(target.root.width, target.root.height).applyCanvas {
|
||||
snapshotStrategy.snapshot(this)
|
||||
}
|
||||
}
|
||||
|
||||
val bodyBubble = target.bubbleView
|
||||
val reactionsView = target.reactionsView
|
||||
|
||||
|
||||
@@ -16,16 +16,18 @@ import org.thoughtcrime.securesms.database.BodyRangeUtil;
|
||||
import org.thoughtcrime.securesms.database.MentionUtil;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
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.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A view level model used to pass arbitrary message related information needed
|
||||
@@ -43,6 +45,7 @@ public class ConversationMessage {
|
||||
@NonNull private final Recipient threadRecipient;
|
||||
private final boolean hasBeenQuoted;
|
||||
@Nullable private final MessageRecord originalMessage;
|
||||
@NonNull private final String formattedDate;
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord,
|
||||
@Nullable CharSequence body,
|
||||
@@ -50,7 +53,8 @@ public class ConversationMessage {
|
||||
boolean hasBeenQuoted,
|
||||
@Nullable MessageStyler.Result styleResult,
|
||||
@NonNull Recipient threadRecipient,
|
||||
@Nullable MessageRecord originalMessage)
|
||||
@Nullable MessageRecord originalMessage,
|
||||
@NonNull String formattedDate)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
this.hasBeenQuoted = hasBeenQuoted;
|
||||
@@ -58,6 +62,7 @@ public class ConversationMessage {
|
||||
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
|
||||
this.threadRecipient = threadRecipient;
|
||||
this.originalMessage = originalMessage;
|
||||
this.formattedDate = formattedDate;
|
||||
|
||||
if (body != null) {
|
||||
this.body = SpannableString.valueOf(body);
|
||||
@@ -90,6 +95,11 @@ public class ConversationMessage {
|
||||
return hasBeenQuoted;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getFormattedDate() {
|
||||
return formattedDate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
@@ -194,13 +204,16 @@ public class ConversationMessage {
|
||||
}
|
||||
}
|
||||
|
||||
String formattedDate = MessageRecordUtil.isScheduled(messageRecord) ? DateUtils.getOnlyTimeString(context, Locale.getDefault(), ((MediaMmsMessageRecord) messageRecord).getScheduledDate())
|
||||
: DateUtils.getDatelessRelativeTimeSpanString(context, Locale.getDefault(), messageRecord.getTimestamp());
|
||||
return new ConversationMessage(messageRecord,
|
||||
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
|
||||
mentionsUpdate != null ? mentionsUpdate.getMentions() : null,
|
||||
hasBeenQuoted,
|
||||
styleResult,
|
||||
threadRecipient,
|
||||
originalMessage);
|
||||
originalMessage,
|
||||
formattedDate);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,10 +189,6 @@ internal object ConversationOptionsMenu {
|
||||
}
|
||||
})
|
||||
|
||||
if (threadId == -1L) {
|
||||
hideMenuItem(menu, R.id.menu_view_media)
|
||||
}
|
||||
|
||||
menu.findItem(R.id.menu_format_text_submenu).subMenu?.clearHeader()
|
||||
menu.findItem(R.id.edittext_bold).applyTitleSpan(MessageStyler.boldStyle())
|
||||
menu.findItem(R.id.edittext_italic).applyTitleSpan(MessageStyler.italicStyle())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user