mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-13 13:33:20 +01:00
Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e11750fb75 | ||
|
|
1634ddeb25 | ||
|
|
7d4bcd7f15 | ||
|
|
13d9b6cc5a | ||
|
|
8d0c41baa0 | ||
|
|
0303c96ee1 | ||
|
|
fde6d7921e | ||
|
|
c632d8ebec | ||
|
|
31b43e8754 | ||
|
|
195360a0f9 | ||
|
|
f293f88958 | ||
|
|
6ccfab4087 | ||
|
|
a45ce55808 | ||
|
|
c7dabe1b6f | ||
|
|
ec51268439 | ||
|
|
7543b9fa37 | ||
|
|
ca3187d0b8 | ||
|
|
327cd93e3c | ||
|
|
13853c708e | ||
|
|
ee1291c816 | ||
|
|
6d2d3ae528 | ||
|
|
784f94ecdb | ||
|
|
93bf853b5e | ||
|
|
bb83ddfe28 | ||
|
|
b51ec53e33 | ||
|
|
ca210f2b6d | ||
|
|
38bddec4ba | ||
|
|
b866d57814 | ||
|
|
3c9004d87d | ||
|
|
c479dd404c | ||
|
|
748667a0b4 | ||
|
|
6898595f8a | ||
|
|
30d0b6fd0e | ||
|
|
7c209db146 | ||
|
|
49c8c88a22 | ||
|
|
88e530c96c | ||
|
|
14f3fb5a94 | ||
|
|
7ac479b78a | ||
|
|
10b356e642 | ||
|
|
7f92482d7a | ||
|
|
b79a7309aa | ||
|
|
b54781ff56 | ||
|
|
6a87495a6d | ||
|
|
c5d9346370 | ||
|
|
d247e2c111 | ||
|
|
b30f47bac4 | ||
|
|
2f9498e137 | ||
|
|
067b3513b7 | ||
|
|
e50ed22c85 | ||
|
|
7cf17f3cc4 | ||
|
|
9f52ecab5c | ||
|
|
c8a56d4f78 | ||
|
|
71d482ab29 | ||
|
|
1cc7b46555 | ||
|
|
f56a65d30d | ||
|
|
aff813b284 | ||
|
|
181c0e8a60 | ||
|
|
cf7f614296 | ||
|
|
351e37bcee | ||
|
|
cc1f27f588 | ||
|
|
859905c3e4 | ||
|
|
8af91bffb5 | ||
|
|
06dc8ccbdd | ||
|
|
c501a417bb | ||
|
|
0021e229d8 | ||
|
|
b4ef95a9b4 | ||
|
|
f25a716d62 | ||
|
|
a9739ed500 | ||
|
|
a131eeaa4a | ||
|
|
9382bbd8fd | ||
|
|
adb1e292bf | ||
|
|
ca79929141 | ||
|
|
ae2998bcf2 | ||
|
|
1e8e09d5c4 | ||
|
|
a5e30bc818 | ||
|
|
72edf5c08b | ||
|
|
192154a11c | ||
|
|
c3700cf6d9 | ||
|
|
78bdee61ef | ||
|
|
b84eea9620 | ||
|
|
5f289fa400 | ||
|
|
67b8f468e4 | ||
|
|
9c49c84306 | ||
|
|
b31ee802fc | ||
|
|
b893a0eb76 | ||
|
|
6f1a04abce | ||
|
|
041ba27efe | ||
|
|
e239036d8b | ||
|
|
d3f073e573 | ||
|
|
0b7490dc06 | ||
|
|
a0e514dac9 | ||
|
|
2f1eaf7d6b | ||
|
|
6cb8b1c439 | ||
|
|
5363208e4e | ||
|
|
510ff51198 | ||
|
|
6dde3d55ef | ||
|
|
e3ec53c2d0 | ||
|
|
e7972d4903 | ||
|
|
a2c3b5d64e | ||
|
|
b11d653fc0 | ||
|
|
66792f2d56 | ||
|
|
c012ead143 | ||
|
|
82906aee58 | ||
|
|
7ff4a82755 | ||
|
|
8ca49c1e18 | ||
|
|
7d68a57f53 | ||
|
|
c68487c0c7 | ||
|
|
4adc660705 | ||
|
|
d78e73bd6f | ||
|
|
9d33690f34 | ||
|
|
4c428e5b5b | ||
|
|
4c3882689f | ||
|
|
c82ed473fc | ||
|
|
06664b4c58 | ||
|
|
5e68388b01 |
@@ -5,4 +5,5 @@ indent_size = 2
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = false
|
||||
ij_kotlin_allow_trailing_comma = false
|
||||
ktlint_code_style = intellij_idea
|
||||
twitter_compose_allowed_composition_locals=LocalExtendedColors
|
||||
twitter_compose_allowed_composition_locals=LocalExtendedColors
|
||||
ktlint_standard_class-naming = disabled
|
||||
@@ -10,7 +10,6 @@ plugins {
|
||||
id 'app.cash.exhaustive'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.squareup.wire'
|
||||
id 'android-constants'
|
||||
id 'translations'
|
||||
}
|
||||
|
||||
@@ -45,8 +44,8 @@ ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1303
|
||||
def canonicalVersionName = "6.27.9"
|
||||
def canonicalVersionCode = 1319
|
||||
def canonicalVersionName = "6.30.4"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -94,7 +93,7 @@ android {
|
||||
testBuildType 'instrumentation'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
@@ -382,7 +381,7 @@ android {
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
|
||||
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
|
||||
"\"ee1d0d972b7ea903615670de43ab1b6e7a825e811c70a29bb5fe0f819e0975fa\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||
@@ -511,7 +510,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
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.testing.timeout
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.push.MismatchedDevices
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyState
|
||||
import java.util.UUID
|
||||
@@ -73,7 +74,7 @@ class ChangeNumberViewModelTest {
|
||||
fun testChangeNumber_givenOnlyPrimaryAndNoRegLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
@@ -180,7 +181,7 @@ class ChangeNumberViewModelTest {
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
@@ -225,7 +226,7 @@ class ChangeNumberViewModelTest {
|
||||
fun testChangeNumber_givenOnlyPrimaryAndRegistrationLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
@@ -269,7 +270,7 @@ class ChangeNumberViewModelTest {
|
||||
fun testChangeNumber_givenMismatchedDevicesOnFirstCall() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
@@ -313,7 +314,7 @@ class ChangeNumberViewModelTest {
|
||||
fun testChangeNumber_givenRegLockAndMismatchedDevicesOnFirstTwoCalls() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.UUID
|
||||
|
||||
class DistributionListTablesTest {
|
||||
|
||||
@@ -294,12 +294,12 @@ class GroupTableTest {
|
||||
private fun insertPushGroup(
|
||||
members: List<DecryptedMember> = listOf(
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setAciBytes(harness.self.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
|
||||
.setAciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
@@ -318,14 +318,14 @@ class GroupTableTest {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
|
||||
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setAciBytes(harness.self.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
|
||||
val otherMembers: List<DecryptedMember> = others.map { id ->
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
|
||||
.setAciBytes(Recipient.resolved(id).requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
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
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -34,7 +33,7 @@ class MessageTableTest_gifts {
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -17,7 +17,6 @@ object MmsHelper {
|
||||
recipient: Recipient = Recipient.UNKNOWN,
|
||||
body: String = "body",
|
||||
sentTimeMillis: Long = System.currentTimeMillis(),
|
||||
subscriptionId: Int = -1,
|
||||
expiresIn: Long = 0,
|
||||
viewOnce: Boolean = false,
|
||||
distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
|
||||
@@ -32,7 +31,6 @@ object MmsHelper {
|
||||
recipient = recipient,
|
||||
body = body,
|
||||
timestamp = sentTimeMillis,
|
||||
subscriptionId = subscriptionId,
|
||||
expiresIn = expiresIn,
|
||||
viewOnce = viewOnce,
|
||||
distributionType = distributionType,
|
||||
|
||||
@@ -16,9 +16,8 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
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
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -45,7 +44,7 @@ class MmsTableTest_stories {
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
|
||||
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
|
||||
|
||||
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelRecipient.id)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -24,14 +24,14 @@ class RecipientTableTest {
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results = SignalDatabase.recipients.queryAllContacts("Hidden")!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
assertEquals(1, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,10 +173,10 @@ class RecipientTableTest {
|
||||
|
||||
SignalDatabase.recipients.markUnregistered(mainId)
|
||||
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
|
||||
|
||||
assertEquals(mainId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
@@ -192,10 +192,10 @@ class RecipientTableTest {
|
||||
|
||||
SignalDatabase.recipients.splitForStorageSync(mainRecord.storageId!!)
|
||||
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
|
||||
|
||||
assertEquals(mainId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.hamcrest.MatcherAssert
|
||||
import org.hamcrest.Matchers
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -14,6 +15,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
@@ -36,14 +38,13 @@ import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -84,6 +85,30 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
|
||||
test("e164+pni insert") {
|
||||
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") {
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,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") {
|
||||
@@ -103,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)
|
||||
@@ -167,6 +197,12 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, new aci, existing pni session, pni-verified") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A, pniVerified = true)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 and aci matches, all provided, new pni") {
|
||||
given(E164_A, null, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
@@ -309,6 +345,26 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, pni is changed") {
|
||||
given(E164_A, PNI_B, ACI_A)
|
||||
given(E164_B, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_B, null, null)
|
||||
}
|
||||
|
||||
test("steal, pni is changed, aci left behind") {
|
||||
given(E164_B, PNI_A, ACI_A)
|
||||
given(E164_A, PNI_B, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_B, null, ACI_A)
|
||||
expect(E164_A, PNI_A, null)
|
||||
}
|
||||
|
||||
test("steal, e164+pni & e164+pni, no aci provided, no pni session") {
|
||||
given(E164_A, PNI_B, null)
|
||||
given(E164_B, PNI_A, null)
|
||||
@@ -353,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)
|
||||
|
||||
@@ -366,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)
|
||||
@@ -378,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)
|
||||
@@ -502,7 +626,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci, pni session, thread merge shadows") {
|
||||
test("merge, e164+pni & aci, pni session, thread merge shadows SSE") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
@@ -600,6 +724,18 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
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("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
|
||||
given(E164_SELF, null, ACI_SELF)
|
||||
given(null, null, ACI_A)
|
||||
@@ -621,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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -768,9 +920,15 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
val byteArray = ByteArray(32)
|
||||
byteArray[0] = value
|
||||
return identityKey(byteArray)
|
||||
}
|
||||
|
||||
private fun identityKey(value: ByteArray): IdentityKey {
|
||||
val bytes = ByteArray(33)
|
||||
bytes[0] = 0x05
|
||||
bytes[1] = value
|
||||
value.copyInto(bytes, 1)
|
||||
return IdentityKey(bytes)
|
||||
}
|
||||
|
||||
@@ -867,14 +1025,15 @@ 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
|
||||
if (createThread) {
|
||||
// Create a thread and throw a dummy message in it so it doesn't get automatically deleted
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
|
||||
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, (Math.random() * Long.MAX_VALUE).toLong(), 0, 0, "", Optional.empty(), 0, false, ""), ""))
|
||||
val result = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = id, time = (Math.random() * 10000000).toLong(), body = "1"))
|
||||
SignalDatabase.threads.markAsActiveEarly(result.get().threadId)
|
||||
}
|
||||
|
||||
if (pniSession) {
|
||||
@@ -885,11 +1044,42 @@ 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(),
|
||||
recipientId = id,
|
||||
identityKey = identityKey(Util.getSecretBytes(32)),
|
||||
verifiedStatus = IdentityTable.VerifiedStatus.DEFAULT,
|
||||
firstUse = true,
|
||||
timestamp = 0,
|
||||
nonBlockingApproval = false
|
||||
)
|
||||
}
|
||||
if (pni != null) {
|
||||
SignalDatabase.identities.saveIdentity(
|
||||
addressName = pni.toString(),
|
||||
recipientId = id,
|
||||
identityKey = identityKey(Util.getSecretBytes(32)),
|
||||
verifiedStatus = IdentityTable.VerifiedStatus.DEFAULT,
|
||||
firstUse = true,
|
||||
timestamp = 0,
|
||||
nonBlockingApproval = false
|
||||
)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false, pniVerified: Boolean = false): RecipientId {
|
||||
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
|
||||
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci = aci, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
|
||||
generatedIds += outputRecipientId
|
||||
return outputRecipientId
|
||||
}
|
||||
@@ -903,15 +1093,15 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
val expected = RecipientTuple(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
serviceId = aci ?: pni
|
||||
aci = aci
|
||||
)
|
||||
val actual = RecipientTuple(
|
||||
e164 = recipient.e164.orElse(null),
|
||||
pni = recipient.pni.orElse(null),
|
||||
serviceId = recipient.serviceId.orElse(null)
|
||||
aci = recipient.aci.orElse(null)
|
||||
)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
assertEquals("Recipient $id did not match expected result!", expected, actual)
|
||||
}
|
||||
|
||||
fun expectDeleted() {
|
||||
@@ -919,21 +1109,21 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
|
||||
fun expectDeleted(id: RecipientId) {
|
||||
SignalDatabase.rawDatabase
|
||||
.select("1")
|
||||
.from(RecipientTable.TABLE_NAME)
|
||||
val found = SignalDatabase.rawDatabase
|
||||
.exists(RecipientTable.TABLE_NAME)
|
||||
.where("${RecipientTable.ID} = ?", id)
|
||||
.run()
|
||||
.use { !it.moveToFirst() }
|
||||
|
||||
assertFalse("Expected $id to be deleted, but it's still present!", found)
|
||||
}
|
||||
|
||||
fun expectChangeNumberEvent() {
|
||||
assertEquals(1, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
assertEquals("Missing change number event!", 1, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
changeNumberExpected = true
|
||||
}
|
||||
|
||||
fun expectNoChangeNumberEvent() {
|
||||
assertEquals(0, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
assertEquals("Unexpected change number event!", 0, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
changeNumberExpected = false
|
||||
}
|
||||
|
||||
@@ -943,42 +1133,39 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
fun expectSessionSwitchoverEvent(recipientId: RecipientId, e164: String) {
|
||||
val event: SessionSwitchoverEvent? = getLatestSessionSwitchoverEvent(recipientId)
|
||||
assertNotNull(event)
|
||||
assertNotNull("Missing session switchover event! Expected one with e164 = $e164", event)
|
||||
assertEquals(e164, event!!.e164)
|
||||
sessionSwitchoverExpected = true
|
||||
}
|
||||
|
||||
fun expectNoSessionSwitchoverEvent() {
|
||||
assertNull(getLatestSessionSwitchoverEvent(outputRecipientId))
|
||||
assertNull("Unexpected session switchover event!", getLatestSessionSwitchoverEvent(outputRecipientId))
|
||||
}
|
||||
|
||||
fun expectThreadMergeEvent(previousE164: String) {
|
||||
val event: ThreadMergeEvent? = getLatestThreadMergeEvent(outputRecipientId)
|
||||
assertNotNull(event)
|
||||
assertEquals(previousE164, event!!.previousE164)
|
||||
assertNotNull("Missing thread merge event! Expected one with e164 = $previousE164", event)
|
||||
assertEquals("E164 on thread merge event doesn't match!", previousE164, event!!.previousE164)
|
||||
threadMergeExpected = true
|
||||
}
|
||||
|
||||
fun expectNoThreadMergeEvent() {
|
||||
assertNull(getLatestThreadMergeEvent(outputRecipientId))
|
||||
assertNull("Unexpected thread merge event!", getLatestThreadMergeEvent(outputRecipientId))
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val serviceIdString: String? = (aci ?: pni)?.toString()
|
||||
val pniString: String? = pni?.toString()
|
||||
|
||||
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||
RecipientTable.TABLE_NAME,
|
||||
null,
|
||||
contentValuesOf(
|
||||
RecipientTable.PHONE to e164,
|
||||
RecipientTable.SERVICE_ID to serviceIdString,
|
||||
RecipientTable.PNI_COLUMN to pniString,
|
||||
RecipientTable.E164 to e164,
|
||||
RecipientTable.ACI_COLUMN to aci?.toString(),
|
||||
RecipientTable.PNI_COLUMN to pni?.toString(),
|
||||
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue("Failed to insert! E164: $e164, ServiceId: $serviceIdString, PNI: $pniString", id > 0)
|
||||
assertTrue("Failed to insert! E164: $e164, ACI: $aci, PNI: $pni", id > 0)
|
||||
|
||||
return RecipientId.from(id)
|
||||
}
|
||||
@@ -987,14 +1174,14 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
data class RecipientTuple(
|
||||
val e164: String?,
|
||||
val pni: PNI?,
|
||||
val serviceId: ServiceId?
|
||||
val aci: ACI?
|
||||
) {
|
||||
|
||||
/**
|
||||
* The intent here is to give nice diffs with the name of the constants rather than the values.
|
||||
*/
|
||||
override fun toString(): String {
|
||||
return "(${e164.e164String()}, ${pni.pniString()}, ${serviceId.serviceIdString()})"
|
||||
return "(${e164.e164String()}, ${pni.pniString()}, ${aci.aciString()})"
|
||||
}
|
||||
|
||||
private fun String?.e164String(): String {
|
||||
@@ -1018,12 +1205,9 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
} ?: "null"
|
||||
}
|
||||
|
||||
private fun ServiceId?.serviceIdString(): String {
|
||||
private fun ACI?.aciString(): String {
|
||||
return this?.let {
|
||||
when (it) {
|
||||
PNI_A -> "PNI_A"
|
||||
PNI_B -> "PNI_B"
|
||||
PNI_SELF -> "PNI_SELF"
|
||||
ACI_A -> "ACI_A"
|
||||
ACI_B -> "ACI_B"
|
||||
ACI_SELF -> "ACI_SELF"
|
||||
|
||||
@@ -21,9 +21,8 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -283,8 +282,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val aliceServiceId: ServiceId = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||
private val bobServiceId: ServiceId = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
private val aliceServiceId: ACI = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||
private val bobServiceId: ACI = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
|
||||
private val masterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||
private val groupId = GroupId.v2(masterKey)
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -465,7 +465,7 @@ class StorySendTableTest {
|
||||
|
||||
private fun makeRecipients(count: Int): List<RecipientId> {
|
||||
return (1..count).map {
|
||||
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))
|
||||
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.junit.Test
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -28,7 +28,7 @@ class ThreadTableTest_active {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -23,7 +23,7 @@ class ThreadTableTest_pinned {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -25,7 +25,7 @@ class ThreadTableTest_recents {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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().uuid()).build(),
|
||||
metadata = metadata(
|
||||
address = address(uuid = messageSender.requireServiceId().uuid()).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()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(sender.requireServiceId().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),
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -14,8 +14,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
|
||||
@@ -39,14 +39,13 @@ class ContactRecordProcessorTest {
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setServiceId(ACI_A.toString())
|
||||
setAci(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(100)
|
||||
}
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setServiceId(PNI_A.toString())
|
||||
setServicePni(PNI_A.toString())
|
||||
setServiceE164(E164_A)
|
||||
setPni(PNI_A.toString())
|
||||
setE164(E164_A)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
@@ -54,10 +53,10 @@ class ContactRecordProcessorTest {
|
||||
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
|
||||
|
||||
// THEN
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
|
||||
|
||||
assertEquals(originalId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
@@ -71,14 +70,14 @@ class ContactRecordProcessorTest {
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setServiceId(ACI_A.toString())
|
||||
setAci(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(0)
|
||||
}
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setServiceId(PNI_A.toString())
|
||||
setServicePni(PNI_A.toString())
|
||||
setServiceE164(E164_A)
|
||||
setAci(PNI_A.toString())
|
||||
setPni(PNI_A.toString())
|
||||
setE164(E164_A)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
@@ -86,7 +85,7 @@ class ContactRecordProcessorTest {
|
||||
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
|
||||
|
||||
// THEN
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
|
||||
|
||||
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
|
||||
trustRoot = trustRoot,
|
||||
uuid = serviceId.uuid(),
|
||||
uuid = serviceId.rawUuid,
|
||||
e164 = e164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,
|
||||
|
||||
@@ -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.
|
||||
@@ -50,7 +50,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
private val serviceAddress = SignalServiceAddress(serviceId, e164)
|
||||
private val registrationId = KeyHelper.generateRegistrationId(false)
|
||||
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
|
||||
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.uuid(), e164, 1, identityKeyPair.publicKey.publicKey, 31337)
|
||||
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
|
||||
private val sessionLock = object : SignalSessionLock {
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Helper methods for creating groups for message processing tests et al.
|
||||
*/
|
||||
object GroupTestingUtils {
|
||||
fun member(serviceId: ServiceId, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
|
||||
fun member(aci: ACI, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
|
||||
return DecryptedMember.newBuilder()
|
||||
.setUuid(serviceId.toByteString())
|
||||
.setAciBytes(aci.toByteString())
|
||||
.setJoinedAtRevision(revision)
|
||||
.setRole(role)
|
||||
.build()
|
||||
@@ -43,7 +43,7 @@ object GroupTestingUtils {
|
||||
}
|
||||
|
||||
fun Recipient.asMember(): DecryptedMember {
|
||||
return member(serviceId = requireServiceId())
|
||||
return member(aci = requireAci())
|
||||
}
|
||||
|
||||
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.registration.VerifyResponse
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
|
||||
@@ -4,8 +4,8 @@ import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
@@ -17,7 +17,7 @@ class TestProtos private constructor() {
|
||||
uuid: UUID = UUID.randomUUID()
|
||||
): AddressProto.Builder {
|
||||
return AddressProto.newBuilder()
|
||||
.setUuid(ServiceId.from(uuid).toByteString())
|
||||
.setUuid(ACI.from(uuid).toByteString())
|
||||
}
|
||||
|
||||
fun metadata(
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.account.PreKeyUpload
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponse
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
|
||||
@@ -630,22 +630,11 @@
|
||||
android:value="org.thoughtcrime.securesms.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<activity android:name=".conversation.ConversationActivity"
|
||||
android:windowSoftInputMode="stateUnchanged"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="org.thoughtcrime.securesms.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<activity android:name=".conversation.BubbleConversationActivity"
|
||||
android:theme="@style/Signal.DayNight"
|
||||
android:allowEmbedded="true"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false"/>
|
||||
android:theme="@style/Signal.DayNight"
|
||||
android:allowEmbedded="true"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".conversation.ConversationPopupActivity"
|
||||
android:windowSoftInputMode="stateVisible"
|
||||
@@ -1145,9 +1134,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>
|
||||
|
||||
@@ -1334,7 +1325,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
@@ -6,7 +6,6 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.insights.InsightsOptOut;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
|
||||
@@ -30,7 +29,6 @@ public final class AppInitialization {
|
||||
public static void onFirstEverAppLaunch(@NonNull Context context) {
|
||||
Log.i(TAG, "onFirstEverAppLaunch()");
|
||||
|
||||
InsightsOptOut.userRequestedOptOut(context);
|
||||
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
|
||||
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
|
||||
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
|
||||
@@ -71,7 +69,6 @@ public final class AppInitialization {
|
||||
public static void onRepairFirstEverAppLaunch(@NonNull Context context) {
|
||||
Log.w(TAG, "onRepairFirstEverAppLaunch()");
|
||||
|
||||
InsightsOptOut.userRequestedOptOut(context);
|
||||
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
|
||||
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
|
||||
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -254,13 +254,8 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
for (SelectedContact contact : contacts) {
|
||||
RecipientId recipientId = contact.getOrCreateRecipientId(context);
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
int subscriptionId = recipient.getDefaultSubscriptionId().orElse(-1);
|
||||
|
||||
MessageSender.send(context, OutgoingMessage.sms(recipient, message, subscriptionId), -1L, MessageSender.SendType.SMS, null, null);
|
||||
|
||||
if (recipient.getContactUri() != null) {
|
||||
SignalDatabase.recipients().setHasSentInvite(recipient.getId());
|
||||
}
|
||||
MessageSender.send(context, OutgoingMessage.sms(recipient, message), -1L, MessageSender.SendType.SMS, null, null);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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,12 +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.SlowNotificationsViewModel;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -40,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;
|
||||
|
||||
@@ -74,6 +82,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
});
|
||||
|
||||
lifecycleDisposable.bindTo(this);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this, true);
|
||||
|
||||
@@ -89,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
|
||||
@@ -136,6 +167,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
|
||||
updateTabVisibility();
|
||||
|
||||
slowNotificationsViewModel.checkSlowNotificationHeuristics();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
@@ -78,10 +77,6 @@ public class MainNavigator {
|
||||
activity.startActivity(intent);
|
||||
}
|
||||
|
||||
public void goToInsights() {
|
||||
InsightsLauncher.showInsightsDashboard(activity.getSupportFragmentManager());
|
||||
}
|
||||
|
||||
private @NonNull FragmentManager getFragmentManager() {
|
||||
return activity.getSupportFragmentManager();
|
||||
}
|
||||
|
||||
@@ -371,6 +371,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
.setPositiveButton(R.string.NewConversationActivity__remove,
|
||||
(dialog, which) -> {
|
||||
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
|
||||
onRefresh();
|
||||
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LiveDataReactiveStreams;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter;
|
||||
import androidx.window.layout.DisplayFeature;
|
||||
@@ -50,6 +51,7 @@ import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
@@ -61,6 +63,10 @@ import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
|
||||
import org.thoughtcrime.securesms.components.webrtc.InCallStatus;
|
||||
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
@@ -76,8 +82,10 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallLinkDisconnectReason;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
@@ -93,8 +101,10 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
@@ -109,7 +119,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
/**
|
||||
* ANSWER the call via voice-only.
|
||||
*/
|
||||
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
||||
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
||||
|
||||
/**
|
||||
* ANSWER the call via video.
|
||||
@@ -120,6 +130,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
|
||||
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
|
||||
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
|
||||
@@ -136,6 +147,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
private PictureInPictureParams.Builder pipBuilderParams;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private long lastCallLinkDisconnectDialogShowTime;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -149,6 +162,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
lifecycleDisposable.bindTo(this);
|
||||
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -188,6 +205,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
|
||||
|
||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||
|
||||
initializePendingParticipantFragmentListener();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -239,7 +258,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
super.onPause();
|
||||
|
||||
if (!viewModel.isCallStarting()) {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
finish();
|
||||
}
|
||||
@@ -259,7 +278,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
if (!viewModel.isCallStarting()) {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
|
||||
}
|
||||
@@ -334,6 +353,54 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private void initializePendingParticipantFragmentListener() {
|
||||
if (!FeatureFlags.adHocCalling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getSupportFragmentManager().setFragmentResultListener(
|
||||
PendingParticipantsBottomSheet.REQUEST_KEY,
|
||||
this,
|
||||
(requestKey, result) -> {
|
||||
PendingParticipantsBottomSheet.Action action = PendingParticipantsBottomSheet.getAction(result);
|
||||
List<RecipientId> recipientIds = viewModel.getPendingParticipantsSnapshot()
|
||||
.getUnresolvedPendingParticipants()
|
||||
.stream()
|
||||
.map(r -> r.getRecipient().getId())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
switch (action) {
|
||||
case NONE:
|
||||
break;
|
||||
case APPROVE_ALL:
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__approve_d_requests, recipientIds.size(), recipientIds.size()))
|
||||
.setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size()))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.WebRtcCallActivity__approve_all, (dialog, which) -> {
|
||||
for (RecipientId id : recipientIds) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(id);
|
||||
}
|
||||
})
|
||||
.show();
|
||||
break;
|
||||
case DENY_ALL:
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__deny_d_requests, recipientIds.size(), recipientIds.size()))
|
||||
.setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size()))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.WebRtcCallActivity__deny_all, (dialog, which) -> {
|
||||
for (RecipientId id : recipientIds) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(id);
|
||||
}
|
||||
})
|
||||
.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void initializeScreenshotSecurity() {
|
||||
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
|
||||
@@ -363,21 +430,23 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||
viewModel.getCallTime().observe(this, this::handleCallTime);
|
||||
|
||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
||||
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
|
||||
|
||||
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
|
||||
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
|
||||
viewModel.getOrientationAndLandscapeEnabled(),
|
||||
viewModel.getEphemeralState(),
|
||||
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink))
|
||||
.observe(this, p -> callScreen.updateCallParticipants(p));
|
||||
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
|
||||
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
|
||||
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
|
||||
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
|
||||
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
|
||||
lifecycleDisposable.add(viewModel.shouldShowSpeakerHint().subscribe(this::updateSpeakerHint));
|
||||
|
||||
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state != null) {
|
||||
if (state.needsNewRequestSizes()) {
|
||||
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
|
||||
@@ -397,6 +466,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
|
||||
});
|
||||
|
||||
callScreen.setPendingParticipantsViewListener(new PendingParticipantsViewListener());
|
||||
Disposable disposable = viewModel.getPendingParticipants()
|
||||
.subscribe(callScreen::updatePendingParticipantsList);
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
}
|
||||
|
||||
private void initializePictureInPictureParams() {
|
||||
@@ -458,14 +533,35 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallTime(long callTime) {
|
||||
EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime);
|
||||
private void handleInCallStatus(@NonNull InCallStatus inCallStatus) {
|
||||
if (inCallStatus instanceof InCallStatus.ElapsedTime) {
|
||||
|
||||
if (ellapsedTimeFormatter == null) {
|
||||
return;
|
||||
EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(((InCallStatus.ElapsedTime) inCallStatus).getElapsedTime());
|
||||
|
||||
if (ellapsedTimeFormatter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
|
||||
} else if (inCallStatus instanceof InCallStatus.PendingCallLinkUsers) {
|
||||
int waiting = ((InCallStatus.PendingCallLinkUsers) inCallStatus).getPendingUserCount();
|
||||
|
||||
callScreen.setStatus(getResources().getQuantityString(
|
||||
R.plurals.WebRtcCallActivity__d_people_waiting,
|
||||
waiting,
|
||||
waiting
|
||||
));
|
||||
} else if (inCallStatus instanceof InCallStatus.JoinedCallLinkUsers) {
|
||||
int joined = ((InCallStatus.JoinedCallLinkUsers) inCallStatus).getJoinedUserCount();
|
||||
|
||||
callScreen.setStatus(getResources().getQuantityString(
|
||||
R.plurals.WebRtcCallActivity__d_people,
|
||||
joined,
|
||||
joined
|
||||
));
|
||||
}else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
|
||||
}
|
||||
|
||||
private void handleSetAudioHandset() {
|
||||
@@ -672,7 +768,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onSendAnywayAfterSafetyNumberChange(@NonNull List<RecipientId> changedRecipients) {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
|
||||
if (state == null) {
|
||||
return;
|
||||
@@ -686,11 +782,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageResentAfterSafetyNumberChange() { }
|
||||
public void onMessageResentAfterSafetyNumberChange() {}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state != null && state.getGroupCallState().isNotIdle()) {
|
||||
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
|
||||
@@ -758,6 +854,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
handleUntrustedIdentity(event); break;
|
||||
}
|
||||
|
||||
if (event.getCallLinkDisconnectReason() != null && event.getCallLinkDisconnectReason().getPostedAt() > lastCallLinkDisconnectDialogShowTime) {
|
||||
lastCallLinkDisconnectDialogShowTime = System.currentTimeMillis();
|
||||
|
||||
if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.RemovedFromCall) {
|
||||
displayRemovedFromCallLinkDialog();
|
||||
} else if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.DeniedRequestToJoinCall) {
|
||||
displayDeniedRequestToJoinCallLinkDialog();
|
||||
} else {
|
||||
throw new AssertionError("Unexpected reason: " + event.getCallLinkDisconnectReason());
|
||||
}
|
||||
}
|
||||
|
||||
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
|
||||
|
||||
viewModel.updateFromWebRtcViewModel(event, enableVideo);
|
||||
@@ -779,6 +887,22 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private void displayRemovedFromCallLinkDialog() {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.WebRtcCallActivity__removed_from_call)
|
||||
.setMessage(R.string.WebRtcCallActivity__someone_has_removed_you_from_the_call)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void displayDeniedRequestToJoinCallLinkDialog() {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.WebRtcCallActivity__join_request_denied)
|
||||
.setMessage(R.string.WebRtcCallActivity__your_request_to_join_this_call_has_been_denied)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||
if (event.getGroupState().isNotIdle()) {
|
||||
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
||||
@@ -833,6 +957,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
|
||||
maybeDisplaySpeakerphonePopup(audioOutput);
|
||||
switch (audioOutput) {
|
||||
case HANDSET:
|
||||
handleSetAudioHandset();
|
||||
@@ -853,8 +978,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@RequiresApi(31)
|
||||
@Override
|
||||
public void onAudioOutputChanged31(@NonNull Integer audioDeviceInfo) {
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
|
||||
public void onAudioOutputChanged31(@NonNull WebRtcAudioDevice audioOutput) {
|
||||
maybeDisplaySpeakerphonePopup(audioOutput.getWebRtcAudioOutput());
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioOutput.getDeviceId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -937,6 +1063,33 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeDisplaySpeakerphonePopup(WebRtcAudioOutput nextOutput) {
|
||||
final WebRtcAudioOutput currentOutput = viewModel.getCurrentAudioOutput();
|
||||
if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) {
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_OFF);
|
||||
} else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) {
|
||||
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_ON);
|
||||
}
|
||||
}
|
||||
|
||||
private class PendingParticipantsViewListener implements PendingParticipantsView.Listener {
|
||||
|
||||
@Override
|
||||
public void onAllowPendingRecipient(@NonNull Recipient pendingRecipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRejectPendingRecipient(@NonNull Recipient pendingRecipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLaunchPendingRequestsSheet() {
|
||||
new PendingParticipantsBottomSheet().show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
||||
}
|
||||
}
|
||||
|
||||
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -64,11 +64,21 @@ public class AudioRecorder {
|
||||
}
|
||||
|
||||
public @NonNull Single<VoiceNoteDraft> startRecording() {
|
||||
Log.i(TAG, "startRecording()");
|
||||
return startRecording(Build.VERSION.SDK_INT >= 26);
|
||||
}
|
||||
|
||||
public @NonNull Single<VoiceNoteDraft> startRecording(final boolean useMediaCodecWrapper) {
|
||||
Log.i(TAG, "startRecording(" + useMediaCodecWrapper + ")");
|
||||
|
||||
final SingleSubject<VoiceNoteDraft> recordingSingle = SingleSubject.create();
|
||||
startRecordingInternal(useMediaCodecWrapper, recordingSingle);
|
||||
|
||||
return recordingSingle;
|
||||
}
|
||||
|
||||
private void startRecordingInternal(boolean useMediaRecorderWrapper, SingleSubject<VoiceNoteDraft> recordingSingle) {
|
||||
executor.execute(() -> {
|
||||
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
|
||||
Log.i(TAG, "Running startRecording(" + useMediaRecorderWrapper + ") + " + Thread.currentThread().getId());
|
||||
try {
|
||||
if (recorder != null) {
|
||||
recordingSingle.onError(new IllegalStateException("We can only do one recording at a time!"));
|
||||
@@ -82,7 +92,7 @@ public class AudioRecorder {
|
||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||
.createForDraftAttachmentAsync(context);
|
||||
|
||||
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
recorder = useMediaRecorderWrapper ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
int focusResult = audioFocusManager.requestAudioFocus();
|
||||
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
|
||||
@@ -90,13 +100,17 @@ public class AudioRecorder {
|
||||
recorder.start(fds[1]);
|
||||
this.recordingSubject = recordingSingle;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
recordingSingle.onError(e);
|
||||
recorder = null;
|
||||
Log.w(TAG, e);
|
||||
recordingUriFuture = null;
|
||||
recorder = null;
|
||||
audioFocusManager.abandonAudioFocus();
|
||||
if (useMediaRecorderWrapper) {
|
||||
startRecordingInternal(false, recordingSingle);
|
||||
} else {
|
||||
recordingSingle.onError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return recordingSingle;
|
||||
}
|
||||
|
||||
public void stopRecording() {
|
||||
|
||||
@@ -41,7 +41,7 @@ public class MediaRecorderWrapper implements Recorder {
|
||||
recorder.setAudioChannels(CHANNELS);
|
||||
recorder.prepare();
|
||||
recorder.start();
|
||||
} catch (IllegalStateException e) {
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "Unable to start recording", e);
|
||||
recorder.release();
|
||||
recorder = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,10 @@ object CallLinks {
|
||||
|
||||
@JvmStatic
|
||||
fun isCallLink(url: String): Boolean {
|
||||
if (!FeatureFlags.adHocCalling()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
|
||||
Log.w(TAG, "Invalid url prefix.")
|
||||
return false
|
||||
@@ -63,6 +68,10 @@ object CallLinks {
|
||||
|
||||
@JvmStatic
|
||||
fun parseUrl(url: String): CallLinkRootKey? {
|
||||
if (!FeatureFlags.adHocCalling()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
|
||||
Log.w(TAG, "Invalid url prefix.")
|
||||
return null
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
@@ -35,7 +36,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
@@ -49,10 +49,10 @@ import java.time.Instant
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials.generate()
|
||||
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
@@ -76,6 +76,14 @@ fun SignalCallRow(
|
||||
onJoinClicked: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val callUrl = if (LocalInspectionMode.current) {
|
||||
"https://signal.call.example.com"
|
||||
} else {
|
||||
remember(callLink.credentials) {
|
||||
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -113,7 +121,7 @@ fun SignalCallRow(
|
||||
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
)
|
||||
Text(
|
||||
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
|
||||
text = callUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
@@ -58,6 +59,7 @@ class UpdateCallLinkRepository(
|
||||
return { result ->
|
||||
if (result is UpdateCallLinkResult.Success) {
|
||||
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
|
||||
ApplicationDependencies.getJobManager().add(CallLinkUpdateSendJob(credentials.roomId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -61,6 +62,7 @@ class CreateCallLinkViewModel(
|
||||
|
||||
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
|
||||
return repository.ensureCallLinkCreated(credentials)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
@@ -74,10 +76,12 @@ class CreateCallLinkViewModel(
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
|
||||
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
|
||||
@@ -91,5 +95,6 @@ class CreateCallLinkViewModel(
|
||||
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,18 +244,20 @@ private fun CallLinkDetails(
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
if (state.callLink.credentials?.adminPassBytes != null) {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged
|
||||
)
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
|
||||
@@ -215,7 +215,15 @@ class CallLogAdapter(
|
||||
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
|
||||
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
|
||||
|
||||
TextViewCompat.setCompoundDrawableTintList(
|
||||
binding.callInfo,
|
||||
ColorStateList.valueOf(
|
||||
ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant)
|
||||
)
|
||||
)
|
||||
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient)
|
||||
}
|
||||
@@ -306,6 +314,7 @@ class CallLogAdapter(
|
||||
when (model.call.record.type) {
|
||||
CallTable.Type.AUDIO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_phone_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_voice_call)
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
@@ -313,6 +322,7 @@ class CallLogAdapter(
|
||||
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
@@ -320,6 +330,7 @@ class CallLogAdapter(
|
||||
|
||||
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
|
||||
@@ -354,7 +365,7 @@ class CallLogAdapter(
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.type == CallTable.Type.AD_HOC_CALL -> R.drawable.symbol_link_compact_16
|
||||
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24
|
||||
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_compact_16
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16
|
||||
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
|
||||
@@ -5,11 +5,14 @@ import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.CallLinkPeekJob
|
||||
import org.thoughtcrime.securesms.jobs.CallLogEventSendJob
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
@@ -80,6 +83,23 @@ class CallLogRepository(
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all call events / unowned links and enqueue clear history job, and then
|
||||
* emit a clear history message.
|
||||
*/
|
||||
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(now)
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(now)
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(now))
|
||||
}
|
||||
|
||||
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
|
||||
}.flatMap(this::revokeAndCollectResults).map { -1 }.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the selected call links. We DELETE those links we don't have admin keys for,
|
||||
* and revoke the ones we *do* have admin keys for. We then perform a cleanup step on
|
||||
@@ -93,19 +113,7 @@ class CallLogRepository(
|
||||
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinks(allCallLinkIds)
|
||||
SignalDatabase.callLinks.getAdminCallLinks(allCallLinkIds)
|
||||
}.flatMap { callLinksToRevoke ->
|
||||
Single.merge(
|
||||
callLinksToRevoke.map {
|
||||
updateCallLinkRepository.revokeCallLink(it.credentials!!)
|
||||
}
|
||||
).reduce(0) { acc, current ->
|
||||
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
|
||||
}
|
||||
}.doOnTerminate {
|
||||
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
|
||||
}.doOnDispose {
|
||||
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,19 +129,21 @@ class CallLogRepository(
|
||||
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
|
||||
SignalDatabase.callLinks.deleteAllNonAdminCallLinksExcept(allCallLinkIds)
|
||||
SignalDatabase.callLinks.getAllAdminCallLinksExcept(allCallLinkIds)
|
||||
}.flatMap { callLinksToRevoke ->
|
||||
Single.merge(
|
||||
callLinksToRevoke.map {
|
||||
updateCallLinkRepository.revokeCallLink(it.credentials!!)
|
||||
}
|
||||
).reduce(0) { acc, current ->
|
||||
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
|
||||
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun revokeAndCollectResults(callLinksToRevoke: Set<CallLinkTable.CallLink>): Single<Int> {
|
||||
return Single.merge(
|
||||
callLinksToRevoke.map {
|
||||
updateCallLinkRepository.revokeCallLink(it.credentials!!)
|
||||
}
|
||||
).reduce(0) { acc, current ->
|
||||
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
|
||||
}.doOnTerminate {
|
||||
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
|
||||
}.doOnDispose {
|
||||
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
fun peekCallLinks(): Completable {
|
||||
|
||||
@@ -93,7 +93,7 @@ sealed class CallLogRow {
|
||||
return FULL
|
||||
}
|
||||
|
||||
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireServiceId().uuid().toString())) {
|
||||
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireAci().rawUuid.toString())) {
|
||||
return LOCAL_USER_JOINED
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ package org.thoughtcrime.securesms.calls.log
|
||||
/**
|
||||
* Selection state object for call logs.
|
||||
*/
|
||||
sealed class CallLogSelectionState {
|
||||
abstract fun contains(callId: CallLogRow.Id): Boolean
|
||||
abstract fun isNotEmpty(totalCount: Int): Boolean
|
||||
sealed interface CallLogSelectionState {
|
||||
fun contains(callId: CallLogRow.Id): Boolean
|
||||
fun isNotEmpty(totalCount: Int): Boolean
|
||||
|
||||
abstract fun count(totalCount: Int): Int
|
||||
fun count(totalCount: Int): Int
|
||||
|
||||
abstract fun selected(): Set<CallLogRow.Id>
|
||||
fun selected(): Set<CallLogRow.Id>
|
||||
fun isExclusionary(): Boolean = this is Excludes
|
||||
|
||||
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
|
||||
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
|
||||
fun select(callId: CallLogRow.Id): CallLogSelectionState
|
||||
fun deselect(callId: CallLogRow.Id): CallLogSelectionState
|
||||
|
||||
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return if (contains(callId)) {
|
||||
@@ -26,7 +26,7 @@ sealed class CallLogSelectionState {
|
||||
/**
|
||||
* Includes contains an opt-in list of call logs.
|
||||
*/
|
||||
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean {
|
||||
return includes.contains(callId)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ sealed class CallLogSelectionState {
|
||||
/**
|
||||
* Excludes contains an opt-out list of call logs.
|
||||
*/
|
||||
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
|
||||
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
|
||||
|
||||
@@ -74,8 +74,10 @@ sealed class CallLogSelectionState {
|
||||
override fun selected(): Set<CallLogRow.Id> = excluded
|
||||
}
|
||||
|
||||
object All : CallLogSelectionState by Excludes(emptySet())
|
||||
|
||||
companion object {
|
||||
fun empty(): CallLogSelectionState = Includes(emptySet())
|
||||
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
|
||||
fun selectAll(): CallLogSelectionState = All
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,21 @@ class CallLogStagedDeletion(
|
||||
.map { it.roomId }
|
||||
.toSet()
|
||||
|
||||
return if (stateSnapshot.isExclusionary()) {
|
||||
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen(
|
||||
repository.deleteAllCallLinksExcept(callRowIds, callLinkIds)
|
||||
)
|
||||
} else {
|
||||
repository.deleteSelectedCallLogs(callRowIds).andThen(
|
||||
repository.deleteSelectedCallLinks(callRowIds, callLinkIds)
|
||||
)
|
||||
return when {
|
||||
stateSnapshot is CallLogSelectionState.All && filter == CallLogFilter.ALL -> {
|
||||
repository.deleteAllCallLogsOnOrBeforeNow()
|
||||
}
|
||||
stateSnapshot is CallLogSelectionState.Excludes || stateSnapshot is CallLogSelectionState.All -> {
|
||||
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen(
|
||||
repository.deleteAllCallLinksExcept(callRowIds, callLinkIds)
|
||||
)
|
||||
}
|
||||
stateSnapshot is CallLogSelectionState.Includes -> {
|
||||
repository.deleteSelectedCallLogs(callRowIds).andThen(
|
||||
repository.deleteSelectedCallLinks(callRowIds, callLinkIds)
|
||||
)
|
||||
}
|
||||
else -> error("Unhandled state $stateSnapshot $filter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,13 +9,11 @@ import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.Selection;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
@@ -49,7 +47,6 @@ import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.List;
|
||||
@@ -65,7 +62,6 @@ public class ComposeText extends EmojiEditText {
|
||||
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
@@ -106,13 +102,7 @@ public class ComposeText extends EmojiEditText {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
|
||||
if (getLayout() != null && !TextUtils.isEmpty(hint)) {
|
||||
if (!TextUtils.isEmpty(subHint)) {
|
||||
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(subHint)));
|
||||
} else {
|
||||
setHintWithChecks(ellipsizeToWidth(hint));
|
||||
}
|
||||
setHintWithChecks(ellipsizeToWidth(hint));
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
}
|
||||
}
|
||||
@@ -173,25 +163,9 @@ public class ComposeText extends EmojiEditText {
|
||||
TruncateAt.END);
|
||||
}
|
||||
|
||||
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
|
||||
public void setHint(@NonNull String hint) {
|
||||
this.hint = hint;
|
||||
|
||||
if (subHint != null) {
|
||||
this.subHint = new SpannableString(subHint);
|
||||
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
} else {
|
||||
this.subHint = null;
|
||||
}
|
||||
|
||||
if (this.subHint != null) {
|
||||
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(this.subHint)));
|
||||
} else {
|
||||
setHintWithChecks(ellipsizeToWidth(this.hint));
|
||||
}
|
||||
|
||||
setHintWithChecks(hint);
|
||||
setHintWithChecks(ellipsizeToWidth(this.hint));
|
||||
}
|
||||
|
||||
public void setDraftText(@Nullable CharSequence draftText) {
|
||||
@@ -249,10 +223,7 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
setImeOptions(imeOptions);
|
||||
setHint(getContext().getString(messageSendType.getComposeHintRes()),
|
||||
messageSendType.getSimName() != null
|
||||
? getContext().getString(R.string.conversation_activity__from_sim_name, messageSendType.getSimName())
|
||||
: null);
|
||||
setHint(getContext().getString(messageSendType.getComposeHintRes()));
|
||||
setInputType(inputType);
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
}
|
||||
String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, timestamp);
|
||||
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord.isEditMessage()) {
|
||||
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {
|
||||
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
|
||||
}
|
||||
dateView.setText(date);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.core.util.ResourceUtil
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
|
||||
class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun show(context: Context, fragmentManager: FragmentManager) {
|
||||
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||
DebugLogsPromptDialogFragment().apply {
|
||||
arguments = bundleOf()
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private val binding by ViewBinderDelegate(PromptLogsBottomSheetBinding::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_logs_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
viewModel = ViewModelProvider(this).get(PromptLogsViewModel::class.java)
|
||||
binding.submit.setOnClickListener {
|
||||
val progressDialog = SignalProgressDialog.show(requireContext())
|
||||
disposables += viewModel.submitLogs().subscribe({ result ->
|
||||
submitLogs(result)
|
||||
progressDialog.dismiss()
|
||||
dismiss()
|
||||
}, { _ ->
|
||||
Toast.makeText(requireContext(), getString(R.string.HelpFragment__could_not_upload_logs), Toast.LENGTH_LONG).show()
|
||||
progressDialog.dismiss()
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
binding.decline.setOnClickListener {
|
||||
SignalStore.uiHints().markDeclinedShareNotificationLogs()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitLogs(debugLog: String) {
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(R.string.DebugLogsPromptDialogFragment__signal_android_support_request),
|
||||
getEmailBody(debugLog)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getEmailBody(debugLog: String?): String {
|
||||
val suffix = StringBuilder()
|
||||
if (debugLog != null) {
|
||||
suffix.append("\n")
|
||||
suffix.append(getString(R.string.HelpFragment__debug_log))
|
||||
suffix.append(" ")
|
||||
suffix.append(debugLog)
|
||||
}
|
||||
val category = ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category)
|
||||
return SupportEmailUtil.generateSupportEmailBody(
|
||||
requireContext(),
|
||||
R.string.DebugLogsPromptDialogFragment__signal_android_support_request,
|
||||
" - $category",
|
||||
"\n\n",
|
||||
suffix.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -48,9 +48,13 @@ public class FromTextView extends SimpleEmojiTextView {
|
||||
}
|
||||
|
||||
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
|
||||
setText(recipient, fromString, read, suffix, true);
|
||||
}
|
||||
|
||||
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix, boolean asThread) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
|
||||
if (recipient.isSelf()) {
|
||||
if (asThread && recipient.isSelf()) {
|
||||
builder.append(getContext().getString(R.string.note_to_self));
|
||||
} else {
|
||||
builder.append(fromString);
|
||||
@@ -60,7 +64,7 @@ public class FromTextView extends SimpleEmojiTextView {
|
||||
builder.append(suffix);
|
||||
}
|
||||
|
||||
if (recipient.showVerified()) {
|
||||
if (asThread && recipient.showVerified()) {
|
||||
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
|
||||
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
@@ -78,7 +79,7 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class InputPanel extends LinearLayout
|
||||
public class InputPanel extends ConstraintLayout
|
||||
implements AudioRecordingHandler,
|
||||
KeyboardAwareLinearLayout.OnKeyboardShownListener,
|
||||
EmojiEventListener,
|
||||
@@ -101,10 +102,10 @@ public class InputPanel extends LinearLayout
|
||||
private SendButton sendButton;
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private ViewGroup composeContainer;
|
||||
private View composeContainer;
|
||||
private View editMessageCancel;
|
||||
private ImageView editMessageThumbnail;
|
||||
private View editMessageHeader;
|
||||
private View editMessageTitle;
|
||||
|
||||
private MicrophoneRecorderView microphoneRecorderView;
|
||||
private SlideToCancel slideToCancel;
|
||||
@@ -163,7 +164,7 @@ public class InputPanel extends LinearLayout
|
||||
TimeUnit.HOURS.toSeconds(1),
|
||||
() -> microphoneRecorderView.cancelAction(false));
|
||||
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
|
||||
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
|
||||
this.editMessageTitle = findViewById(R.id.edit_message_title);
|
||||
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
|
||||
|
||||
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
|
||||
@@ -454,13 +455,15 @@ public class InputPanel extends LinearLayout
|
||||
private void updateEditModeUi() {
|
||||
if (inEditMessageMode()) {
|
||||
ViewUtil.focusAndShowKeyboard(composeText);
|
||||
editMessageHeader.setVisibility(View.VISIBLE);
|
||||
editMessageTitle.setVisibility(View.VISIBLE);
|
||||
editMessageThumbnail.setVisibility(View.VISIBLE);
|
||||
editMessageCancel.setVisibility(View.VISIBLE);
|
||||
if (listener != null) {
|
||||
listener.onEnterEditMode();
|
||||
}
|
||||
} else {
|
||||
editMessageHeader.setVisibility(View.GONE);
|
||||
editMessageTitle.setVisibility(View.GONE);
|
||||
editMessageThumbnail.setVisibility(View.GONE);
|
||||
editMessageCancel.setVisibility(View.GONE);
|
||||
if (listener != null) {
|
||||
listener.onExitEditMode();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.SingleSubject
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
|
||||
class PromptLogsViewModel : ViewModel() {
|
||||
|
||||
private val submitDebugLogRepository = SubmitDebugLogRepository()
|
||||
|
||||
fun submitLogs(): Single<String> {
|
||||
val singleSubject = SingleSubject.create<String?>()
|
||||
submitDebugLogRepository.buildAndSubmitLog { result ->
|
||||
if (result.isPresent) {
|
||||
singleSubject.onSuccess(result.get())
|
||||
} else {
|
||||
singleSubject.onError(Throwable())
|
||||
}
|
||||
}
|
||||
|
||||
return singleSubject.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,8 @@ import android.view.View
|
||||
import android.view.View.OnLongClickListener
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.AppCompatImageButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.lang.AssertionError
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* The send button you see in a conversation.
|
||||
@@ -23,122 +15,21 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
*/
|
||||
class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImageButton(context, attributeSet), OnLongClickListener {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SendButton::class.java)
|
||||
}
|
||||
|
||||
private val listeners: MutableList<SendTypeChangedListener> = CopyOnWriteArrayList()
|
||||
private var scheduledSendListener: ScheduledSendListener? = null
|
||||
|
||||
private var availableSendTypes: List<MessageSendType> = MessageSendType.getAllAvailable(context, false)
|
||||
private var activeMessageSendType: MessageSendType? = null
|
||||
private var defaultTransportType: MessageSendType.TransportType = MessageSendType.TransportType.SIGNAL
|
||||
private var defaultSubscriptionId: Int? = null
|
||||
|
||||
var snackbarContainer: View? = null
|
||||
private var popupContainer: ViewGroup? = null
|
||||
|
||||
init {
|
||||
setOnLongClickListener(this)
|
||||
ViewUtil.mirrorIfRtl(this, getContext())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the [selectedSendType] was chosen manually by the user, otherwise false.
|
||||
*/
|
||||
val isManualSelection: Boolean
|
||||
get() = activeMessageSendType != null
|
||||
|
||||
/**
|
||||
* The actively-selected send type.
|
||||
*/
|
||||
val selectedSendType: MessageSendType
|
||||
get() {
|
||||
activeMessageSendType?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
if (defaultTransportType === MessageSendType.TransportType.SMS) {
|
||||
for (type in availableSendTypes) {
|
||||
if (type.usesSmsTransport && (defaultSubscriptionId == null || type.simSubscriptionId == defaultSubscriptionId)) {
|
||||
return type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (type in availableSendTypes) {
|
||||
if (type.transportType === defaultTransportType) {
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "No options of default type! Resetting. DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
|
||||
|
||||
val signalType: MessageSendType? = availableSendTypes.firstOrNull { it.usesSignalTransport }
|
||||
if (signalType != null) {
|
||||
Log.w(TAG, "No options of default type, but Signal type is available. Switching. DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
|
||||
defaultTransportType = MessageSendType.TransportType.SIGNAL
|
||||
onSelectionChanged(signalType, false)
|
||||
return signalType
|
||||
} else if (availableSendTypes.isEmpty()) {
|
||||
Log.w(TAG, "No send types available at all! Enabling the Signal transport.")
|
||||
defaultTransportType = MessageSendType.TransportType.SIGNAL
|
||||
availableSendTypes = listOf(MessageSendType.SignalMessageSendType)
|
||||
onSelectionChanged(MessageSendType.SignalMessageSendType, false)
|
||||
return MessageSendType.SignalMessageSendType
|
||||
} else {
|
||||
throw AssertionError("No options of default type! DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
|
||||
}
|
||||
}
|
||||
|
||||
fun addOnSelectionChangedListener(listener: SendTypeChangedListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun triggerSelectedChangedEvent() {
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
setImageResource(MessageSendType.SignalMessageSendType.buttonDrawableRes)
|
||||
contentDescription = context.getString(MessageSendType.SignalMessageSendType.titleRes)
|
||||
}
|
||||
|
||||
fun setScheduledSendListener(listener: ScheduledSendListener?) {
|
||||
this.scheduledSendListener = listener
|
||||
}
|
||||
|
||||
fun resetAvailableTransports(isMediaMessage: Boolean) {
|
||||
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
|
||||
activeMessageSendType = null
|
||||
defaultTransportType = MessageSendType.TransportType.SIGNAL
|
||||
defaultSubscriptionId = null
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
}
|
||||
|
||||
fun disableTransportType(type: MessageSendType.TransportType) {
|
||||
availableSendTypes = availableSendTypes.filterNot { it.transportType == type }
|
||||
}
|
||||
|
||||
fun setDefaultTransport(type: MessageSendType.TransportType) {
|
||||
if (defaultTransportType == type) {
|
||||
return
|
||||
}
|
||||
defaultTransportType = type
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
}
|
||||
|
||||
fun setSendType(sendType: MessageSendType?) {
|
||||
if (activeMessageSendType == sendType) {
|
||||
return
|
||||
}
|
||||
activeMessageSendType = sendType
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = true)
|
||||
}
|
||||
|
||||
fun setDefaultSubscriptionId(subscriptionId: Int?) {
|
||||
if (defaultSubscriptionId == subscriptionId) {
|
||||
return
|
||||
}
|
||||
defaultSubscriptionId = subscriptionId
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called with a view that is acceptable for determining the bounds of the popup selector.
|
||||
*/
|
||||
@@ -146,78 +37,19 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
popupContainer = container
|
||||
}
|
||||
|
||||
private fun onSelectionChanged(newType: MessageSendType, isManualSelection: Boolean) {
|
||||
setImageResource(newType.buttonDrawableRes)
|
||||
contentDescription = context.getString(newType.titleRes)
|
||||
|
||||
for (listener in listeners) {
|
||||
listener.onSendTypeChanged(newType, isManualSelection)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSendTypeMenu(): Boolean {
|
||||
return if (availableSendTypes.size == 1) {
|
||||
if (scheduledSendListener == null && snackbarContainer != null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
Snackbar.make(snackbarContainer!!, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
false
|
||||
} else {
|
||||
showSendTypeContextMenu(false)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
if (!isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
val scheduleListener = scheduledSendListener
|
||||
if (availableSendTypes.size == 1) {
|
||||
return if (scheduleListener?.canSchedule() == true && selectedSendType.transportType != MessageSendType.TransportType.SMS) {
|
||||
scheduleListener.onSendScheduled()
|
||||
true
|
||||
} else if (snackbarContainer != null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
Snackbar.make(snackbarContainer!!, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
return if (scheduleListener?.canSchedule() == true) {
|
||||
scheduleListener.onSendScheduled()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
showSendTypeContextMenu(selectedSendType.transportType != MessageSendType.TransportType.SMS)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showSendTypeContextMenu(allowScheduling: Boolean) {
|
||||
val currentlySelected: MessageSendType = selectedSendType
|
||||
val listener = scheduledSendListener
|
||||
val items = availableSendTypes
|
||||
.filterNot { it == currentlySelected }
|
||||
.map { option ->
|
||||
ActionItem(
|
||||
iconRes = option.menuDrawableRes,
|
||||
title = option.getTitle(context),
|
||||
action = { setSendType(option) }
|
||||
)
|
||||
}.toMutableList()
|
||||
if (allowScheduling && listener?.canSchedule() == true) {
|
||||
items += ActionItem(
|
||||
iconRes = R.drawable.symbol_calendar_24,
|
||||
title = context.getString(R.string.conversation_activity__option_schedule_message),
|
||||
action = { listener.onSendScheduled() }
|
||||
)
|
||||
}
|
||||
|
||||
SignalContextMenu.Builder((parent as View), popupContainer!!)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.offsetY(ViewUtil.dpToPx(8))
|
||||
.show(items)
|
||||
}
|
||||
|
||||
fun interface SendTypeChangedListener {
|
||||
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
|
||||
}
|
||||
|
||||
interface ScheduledSendListener {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
@@ -18,6 +19,8 @@ import android.text.method.TransformationMethod;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
@@ -25,6 +28,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.GestureDetectorCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
@@ -315,6 +319,12 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
public void bindGestureListener() {
|
||||
GestureDetectorCompat gestureDetectorCompat = new GestureDetectorCompat(getContext(), new OnGestureListener());
|
||||
setOnTouchListener((v, event) -> gestureDetectorCompat.onTouchEvent(event));
|
||||
}
|
||||
|
||||
private void ellipsizeAnyTextForMaxLength() {
|
||||
if (maxLength > 0 && getText().length() > maxLength + 1) {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
@@ -465,4 +475,32 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
mentionRendererDelegate.setTint(mentionBackgroundTint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to some peculiarities in how TextView deals with touch events, it's really easy to accidentally trigger
|
||||
* a click (say, when you try to scroll but you're at the bottom of a view.) Because of this, we handle these
|
||||
* events manually.
|
||||
*/
|
||||
private class OnGestureListener extends GestureDetector.SimpleOnGestureListener {
|
||||
@Override
|
||||
public boolean onDown(@NonNull MotionEvent e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
|
||||
if (!canScrollVertically((int) distanceY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int maxScrollDistance = computeVerticalScrollRange() - computeHorizontalScrollExtent();
|
||||
scrollTo(0, Util.clamp(getScrollY() + (int) distanceY, 0, maxScrollDistance));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
|
||||
return performClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@@ -25,9 +26,7 @@ public class DozeReminder extends Reminder {
|
||||
|
||||
setOkListener(v -> {
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(context, true);
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:" + context.getPackageName()));
|
||||
context.startActivity(intent);
|
||||
PowerManagerCompat.requestIgnoreBatteryOptimizations(context);
|
||||
});
|
||||
|
||||
setDismissListener(v -> TextSecurePreferences.setPromptedOptimizeDoze(context, true));
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.reminder;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public final class FirstInviteReminder extends Reminder {
|
||||
|
||||
private final int percentIncrease;
|
||||
|
||||
public FirstInviteReminder(final int percentIncrease) {
|
||||
super(R.string.FirstInviteReminder__title, NO_RESOURCE);
|
||||
this.percentIncrease = percentIncrease;
|
||||
|
||||
addAction(new Action(R.string.InsightsReminder__invite, R.id.reminder_action_invite));
|
||||
addAction(new Action(R.string.InsightsReminder__view_insights, R.id.reminder_action_view_insights));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CharSequence getText(@NonNull Context context) {
|
||||
return context.getString(R.string.FirstInviteReminder__description, percentIncrease);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.reminder;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
public final class SecondInviteReminder extends Reminder {
|
||||
|
||||
private final Recipient recipient;
|
||||
private final int progress;
|
||||
|
||||
public SecondInviteReminder(final @NonNull Context context,
|
||||
final @NonNull Recipient recipient,
|
||||
final int percent)
|
||||
{
|
||||
super(R.string.SecondInviteReminder__title, NO_RESOURCE);
|
||||
this.recipient = recipient;
|
||||
|
||||
this.progress = percent;
|
||||
|
||||
addAction(new Action(R.string.InsightsReminder__invite, R.id.reminder_action_invite));
|
||||
addAction(new Action(R.string.InsightsReminder__view_insights, R.id.reminder_action_view_insights));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CharSequence getText(@NonNull Context context) {
|
||||
return context.getString(R.string.SecondInviteReminder__description, recipient.getDisplayName(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.Objects
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
|
||||
|
||||
@@ -34,8 +34,7 @@ import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
|
||||
import org.whispersystems.signalservice.api.account.PreKeyUpload
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
|
||||
@@ -243,7 +242,7 @@ class ChangeNumberRepository(
|
||||
throw AssertionError("No change number metadata")
|
||||
}
|
||||
|
||||
val originalPni = ServiceId.fromByteString(metadata.previousPni)
|
||||
val originalPni = PNI.parseOrThrow(metadata.previousPni)
|
||||
|
||||
if (originalPni == pni) {
|
||||
Log.i(TAG, "No change has occurred, PNI is unchanged: $pni")
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewMod
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.IncorrectCodeException
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Objects
|
||||
|
||||
@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.components.settings.app.internal.search
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@@ -46,7 +45,7 @@ class InternalSearchViewModel : ViewModel() {
|
||||
InternalSearchResult(
|
||||
id = record.id,
|
||||
name = record.displayName(),
|
||||
aci = record.serviceId?.toString(),
|
||||
aci = record.aci?.toString(),
|
||||
pni = record.pni.toString(),
|
||||
groupId = record.groupId
|
||||
)
|
||||
@@ -70,10 +69,10 @@ class InternalSearchViewModel : ViewModel() {
|
||||
|
||||
private fun RecipientRecord.displayName(): String {
|
||||
return when {
|
||||
this.groupType == RecipientTable.GroupType.SIGNAL_V1 -> "GV1::${this.groupId}"
|
||||
this.groupType == RecipientTable.GroupType.SIGNAL_V2 -> "GV2::${this.groupId}"
|
||||
this.groupType == RecipientTable.GroupType.MMS -> "MMS_GROUP::${this.groupId}"
|
||||
this.groupType == RecipientTable.GroupType.DISTRIBUTION_LIST -> "DLIST::${this.distributionListId}"
|
||||
this.recipientType == RecipientTable.RecipientType.GV1 -> "GV1::${this.groupId}"
|
||||
this.recipientType == RecipientTable.RecipientType.GV2 -> "GV2::${this.groupId}"
|
||||
this.recipientType == RecipientTable.RecipientType.MMS -> "MMS_GROUP::${this.groupId}"
|
||||
this.recipientType == RecipientTable.RecipientType.DISTRIBUTION_LIST -> "DLIST::${this.distributionListId}"
|
||||
this.systemDisplayName?.isNotBlank() == true -> this.systemDisplayName
|
||||
this.signalProfileName.toString().isNotBlank() -> this.signalProfileName.serialize()
|
||||
this.e164 != null -> this.e164
|
||||
|
||||
@@ -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
|
||||
@@ -184,6 +185,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(
|
||||
|
||||
@@ -17,7 +17,8 @@ data class MessageNotificationsState(
|
||||
val inChatSoundsEnabled: Boolean,
|
||||
val repeatAlerts: Int,
|
||||
val messagePrivacy: String,
|
||||
val priority: Int
|
||||
val priority: Int,
|
||||
val troubleshootNotifications: Boolean
|
||||
)
|
||||
|
||||
data class CallNotificationsState(
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -104,7 +105,8 @@ 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 = SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.isHavingDelayedNotifications()
|
||||
),
|
||||
callNotificationsState = CallNotificationsState(
|
||||
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled,
|
||||
|
||||
@@ -25,7 +25,6 @@ import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadin
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
@@ -245,15 +244,13 @@ class ManageDonationsFragment :
|
||||
|
||||
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
|
||||
|
||||
if (Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_for_a_friend),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_gift_24),
|
||||
onClick = {
|
||||
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_for_a_friend),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_gift_24),
|
||||
onClick = {
|
||||
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentBadges() {
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -153,8 +153,8 @@ class ConversationSettingsRepository(
|
||||
if (groupRecord.isV2Group) {
|
||||
val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup
|
||||
val pendingMembers: List<RecipientId> = decryptedGroup.pendingMembersList
|
||||
.map(DecryptedPendingMember::getUuid)
|
||||
.map(GroupProtoUtil::uuidByteStringToRecipientId)
|
||||
.map(DecryptedPendingMember::getServiceIdBytes)
|
||||
.map(GroupProtoUtil::serviceIdBinaryToRecipientId)
|
||||
|
||||
val members = mutableListOf<RecipientId>()
|
||||
|
||||
|
||||
@@ -165,8 +165,11 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (recipient.hasServiceId()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
|
||||
if (recipient.hasAci()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireAci().toString())
|
||||
}
|
||||
if (recipient.hasPni()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requirePni().toString())
|
||||
}
|
||||
}
|
||||
.show()
|
||||
@@ -182,14 +185,25 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipient.id))
|
||||
|
||||
if (recipient.hasServiceId()) {
|
||||
SignalDatabase.recipients.debugClearServiceIds(recipient.id)
|
||||
SignalDatabase.recipients.debugClearProfileData(recipient.id)
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requireServiceId().toString())
|
||||
ApplicationDependencies.getProtocolStore().pni().identities().delete(recipient.requireServiceId().toString())
|
||||
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipient.id))
|
||||
}
|
||||
|
||||
if (recipient.hasAci()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireAci().toString())
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requirePni(), addressName = recipient.requireAci().toString())
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requireAci().toString())
|
||||
}
|
||||
|
||||
if (recipient.hasPni()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requirePni().toString())
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requirePni(), addressName = recipient.requirePni().toString())
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requirePni().toString())
|
||||
}
|
||||
|
||||
startActivity(MainActivity.clearTop(requireContext()))
|
||||
}
|
||||
.show()
|
||||
@@ -237,7 +251,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
SignalDatabase.recipients.debugClearE164AndPni(recipient.id)
|
||||
|
||||
val splitRecipientId: RecipientId = if (FeatureFlags.phoneNumberPrivacy()) {
|
||||
SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.pni.orElse(null), recipient.pni.orElse(null), recipient.requireE164())
|
||||
SignalDatabase.recipients.getAndPossiblyMergePnpVerified(null, recipient.pni.orElse(null), recipient.requireE164())
|
||||
} else {
|
||||
SignalDatabase.recipients.getAndPossiblyMerge(recipient.pni.orElse(null), recipient.requireE164())
|
||||
}
|
||||
@@ -281,7 +295,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
SignalDatabase.recipients.debugRemoveAci(recipient.id)
|
||||
|
||||
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireServiceId(), null, null)
|
||||
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireAci(), null, null)
|
||||
|
||||
recipient.profileKey?.let { profileKey ->
|
||||
SignalDatabase.recipients.setProfileKey(aciRecipientId, ProfileKey(profileKey))
|
||||
|
||||
@@ -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,137 +1,104 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
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.checkerframework.checker.units.qual.A;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
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;
|
||||
|
||||
import java.util.Collections;
|
||||
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 SESSION_ID = "VoiceNotePlayback";
|
||||
private static final String EMPTY_ROOT_ID = "empty-root-id";
|
||||
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 = new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build();
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken());
|
||||
|
||||
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
|
||||
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
|
||||
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
|
||||
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 {
|
||||
@@ -150,20 +117,14 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
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;
|
||||
default:
|
||||
becomingNoisyReceiver.unregister();
|
||||
voiceNoteNotificationManager.hideNotification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +159,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
|
||||
|
||||
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
|
||||
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
|
||||
voiceNotePlayerCallback.loadMoreVoiceNotes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,13 +178,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
|
||||
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
|
||||
mediaSession.setPlaybackToLocal(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
|
||||
if (isAudioMessage(currentWindowIndex)) {
|
||||
return voiceNotePlaybackParameters.getParameters();
|
||||
return player.getPlaybackParameters();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -271,49 +231,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 +282,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,321 @@
|
||||
/*
|
||||
* 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.MediaMetadata
|
||||
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.Util
|
||||
import org.thoughtcrime.securesms.util.hasAudio
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.stream.Collectors
|
||||
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) {
|
||||
applyDescriptionsToQueue(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 applyDescriptionsToQueue(mediaItems: List<MediaItem>) {
|
||||
for (mediaItem in mediaItems) {
|
||||
val playbackProperties = mediaItem.localConfiguration ?: continue
|
||||
val holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri)
|
||||
val next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem)
|
||||
val currentIndex: Int = player.currentMediaItemIndex
|
||||
if (holderIndex != -1) {
|
||||
if (currentIndex != holderIndex) {
|
||||
player.removeMediaItem(holderIndex)
|
||||
player.addMediaItem(holderIndex, mediaItem)
|
||||
}
|
||||
if (currentIndex != holderIndex + 1) {
|
||||
if (player.mediaItemCount > 1) {
|
||||
player.removeMediaItem(holderIndex + 1)
|
||||
}
|
||||
player.addMediaItem(holderIndex + 1, next)
|
||||
}
|
||||
} else {
|
||||
val insertLocation = indexAfter(mediaItem)
|
||||
player.addMediaItem(insertLocation, next)
|
||||
player.addMediaItem(insertLocation, mediaItem)
|
||||
}
|
||||
}
|
||||
val itemsCount: Int = player.mediaItemCount
|
||||
if (itemsCount > 0) {
|
||||
val lastIndex = itemsCount - 1
|
||||
val last: MediaItem = player.getMediaItemAt(lastIndex)
|
||||
if (last.localConfiguration?.uri == VoiceNoteMediaItemFactory.NEXT_URI) {
|
||||
player.removeMediaItem(lastIndex)
|
||||
if (player.mediaItemCount > 1) {
|
||||
val end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last)
|
||||
player.addMediaItem(lastIndex, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun indexAfter(target: MediaItem): Int {
|
||||
val size: Int = player.mediaItemCount
|
||||
val targetMessageId = target.mediaMetadata.extras?.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) ?: 0L
|
||||
for (i in 0 until size) {
|
||||
val mediaMetadata: MediaMetadata = player.getMediaItemAt(i).mediaMetadata
|
||||
val messageId = mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
|
||||
if (messageId > targetMessageId) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
fun loadMoreVoiceNotes() {
|
||||
if (!canLoadMore) {
|
||||
return
|
||||
}
|
||||
val currentMediaItem: MediaItem = player.currentMediaItem ?: return
|
||||
val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
|
||||
SimpleTask.run(
|
||||
EXECUTOR,
|
||||
{ loadMediaItemsForConsecutivePlayback(messageId) }
|
||||
) { mediaItems: List<MediaItem> ->
|
||||
if (Util.hasItems(mediaItems) && canLoadMore) {
|
||||
applyDescriptionsToQueue(mediaItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
val messageRecord = messages
|
||||
.getMessageRecord(messageId)
|
||||
if (!messageRecord.hasAudio()) {
|
||||
Log.w(TAG, "Message does not contain audio.")
|
||||
return emptyList<MediaItem>()
|
||||
}
|
||||
val mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord)
|
||||
mediaItem?.let { listOf(it) } ?: emptyList()
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List<MediaItem> {
|
||||
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
|
||||
return try {
|
||||
val recordsAfter = messages.getMessagesAfterVoiceNoteInclusive(messageId, LIMIT)
|
||||
recordsAfter.filter { it.hasAudio() }.stream()
|
||||
.map<MediaItem?> { record: MessageRecord? ->
|
||||
VoiceNoteMediaItemFactory
|
||||
.buildMediaItem(context, record!!)
|
||||
}
|
||||
.filter { obj: MediaItem? -> Objects.nonNull(obj) }
|
||||
.collect(Collectors.toList())
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Could not find message.", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,10 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -45,7 +47,10 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.toLiveData
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -66,6 +71,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -114,9 +121,13 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val callLinkDetailsState by callLinkDetailsViewModel.state
|
||||
val callParticipantsState by webRtcCallViewModel.callParticipantsState.observeAsState()
|
||||
val participants = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
|
||||
listOf(Recipient.self()) + (callParticipantsState?.allRemoteParticipants?.map { it.recipient } ?: emptyList())
|
||||
val callParticipantsState by webRtcCallViewModel.callParticipantsState
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
.observeAsState()
|
||||
|
||||
val participants: ImmutableList<CallParticipant> = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
|
||||
listOf(CallParticipant(recipient = Recipient.self())) + (callParticipantsState?.allRemoteParticipants?.map { it } ?: emptyList())
|
||||
} else {
|
||||
emptyList()
|
||||
}.toImmutableList()
|
||||
@@ -137,11 +148,24 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
|
||||
onShareLinkClicked = this::shareLink,
|
||||
onEditNameClicked = onEditNameClicked,
|
||||
onToggleAdminApprovalClicked = this::onApproveAllMembersChanged,
|
||||
onBlock = {} // TODO [alex] -- Blocking
|
||||
onBlock = this::onBlockParticipant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBlockParticipant(callParticipant: CallParticipant) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setMessage(getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(requireContext())))
|
||||
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
|
||||
ApplicationDependencies.getSignalCallManager().removeFromCallLink(callParticipant)
|
||||
}
|
||||
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
|
||||
ApplicationDependencies.getSignalCallManager().blockFromCallLink(callParticipant)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
callLinkDetailsViewModel.setApproveAllMembers(checked)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -210,7 +234,7 @@ private fun SheetPreview() {
|
||||
),
|
||||
state = SignalCallLinkState()
|
||||
),
|
||||
participants = listOf(Recipient.UNKNOWN).toImmutableList(),
|
||||
participants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)).toImmutableList(),
|
||||
onShareLinkClicked = {},
|
||||
onEditNameClicked = {},
|
||||
onToggleAdminApprovalClicked = {},
|
||||
@@ -223,17 +247,24 @@ private fun SheetPreview() {
|
||||
@Composable
|
||||
private fun Sheet(
|
||||
callLink: CallLinkTable.CallLink,
|
||||
participants: ImmutableList<Recipient>,
|
||||
participants: ImmutableList<CallParticipant>,
|
||||
onShareLinkClicked: () -> Unit,
|
||||
onEditNameClicked: () -> Unit,
|
||||
onToggleAdminApprovalClicked: (Boolean) -> Unit,
|
||||
onBlock: (Recipient) -> Unit
|
||||
onBlock: (CallParticipant) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
|
||||
SignalCallRow(callLink = callLink, onJoinClicked = null)
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
@@ -251,9 +282,9 @@ private fun Sheet(
|
||||
)
|
||||
}
|
||||
|
||||
items(participants, { it.id }, { null }) {
|
||||
items(participants, { it.callParticipantId }, { null }) {
|
||||
CallLinkMemberRow(
|
||||
recipient = it,
|
||||
callParticipant = it,
|
||||
isSelfAdmin = callLink.credentials?.adminPassBytes != null,
|
||||
onBlockClicked = onBlock
|
||||
)
|
||||
@@ -282,7 +313,7 @@ private fun CallLinkMemberRowPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
CallLinkMemberRow(
|
||||
Recipient.UNKNOWN,
|
||||
CallParticipant(recipient = Recipient.UNKNOWN),
|
||||
isSelfAdmin = true,
|
||||
{}
|
||||
)
|
||||
@@ -292,37 +323,45 @@ private fun CallLinkMemberRowPreview() {
|
||||
|
||||
@Composable
|
||||
private fun CallLinkMemberRow(
|
||||
recipient: Recipient,
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: (Recipient) -> Unit
|
||||
onBlockClicked: (CallParticipant) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Rows.defaultPadding())
|
||||
) {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
it.setAvatarUsingProfile(recipient)
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = Color.Red, shape = CircleShape)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
it.setAvatarUsingProfile(callParticipant.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Text(
|
||||
text = recipient.getShortDisplayName(LocalContext.current),
|
||||
text = callParticipant.recipient.getShortDisplayName(LocalContext.current),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
if (isSelfAdmin) {
|
||||
if (isSelfAdmin && !callParticipant.recipient.isSelf) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onBlockClicked(recipient) })
|
||||
.clickable(onClick = { onBlockClicked(callParticipant) })
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user