Merge tag 'v6.20.3' into molly-6.20

This commit is contained in:
Oscar Mira 2023-05-15 17:01:29 +02:00
commit bd2bc4d005
329 changed files with 16877 additions and 6277 deletions

View file

@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright &amp;#36;today.year Signal Messenger, LLC&#10;SPDX-License-Identifier: AGPL-3.0-only" />
<option name="myName" value="Signal" />
</copyright>
</component>

View file

@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="All" copyright="Signal" />
</module2copyright>
</settings>
</component>

View file

@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public @interface ${NAME} {
}

View file

@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public class ${NAME} {
}

View file

@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public enum ${NAME} {
}

View file

@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public interface ${NAME} {
}

View file

@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
class ${NAME} {
}

View file

@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
enum class ${NAME} {
}

View file

@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")

View file

@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
interface ${NAME} {
}

152
LICENSE
View file

@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -619,3 +617,45 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -56,8 +56,8 @@ ext {
MAPS_API_KEY = getEnv('CI_MAPS_API_KEY') ?: mapsApiKey
}
def canonicalVersionCode = 1256
def canonicalVersionName = "6.19.9"
def canonicalVersionCode = 1260
def canonicalVersionName = "6.20.3"
def mollyRevision = 0
def postFixSize = 100
@ -195,7 +195,6 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
@ -347,7 +346,6 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +

View file

@ -114,7 +114,7 @@ class DatabaseConsistencyTest {
.replace(Regex("\\s+"), " ")
.replace(Regex.fromLiteral("( "), "(")
.replace(Regex.fromLiteral(" )"), ")")
.replace(Regex("CREATE TABLE \"([a-z]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
.replace(Regex("CREATE TABLE \"([a-zA-Z_]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
}
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {

View file

@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
import kotlin.time.Duration.Companion.seconds
@ -238,7 +238,7 @@ class EditMessageSyncProcessorTest {
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data

View file

@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
@ -278,7 +278,7 @@ class MessageContentProcessorTestV2 {
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data

View file

@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MessageContentProcessorV2__recipientStatusTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV2: MessageContentProcessorV2
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV2 = MessageContentProcessorV2(harness.context)
envelopeTimestamp = System.currentTimeMillis()
}
/**
* Process sync group sent text transcript with partial send and then process second sync with recipient update
* flag set to true with the rest of the send completed.
*/
@Test
fun syncGroupSentTextMessageWithRecipientUpdateFollowup() {
val (groupId, masterKey, groupRecipientId) = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), harness.others[0].asMember(), harness.others[1].asMember())
val groupContextV2 = GroupContextV2.newBuilder().setRevision(0).setMasterKey(masterKey.serialize().toProtoByteString()).build()
val initialTextMessage = DataMessage.newBuilder().buildWith {
body = MessageContentFuzzer.string()
groupV2 = groupContextV2
timestamp = envelopeTimestamp
}
processorV2.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)
val threadId = SignalDatabase.threads.getThreadIdFor(groupRecipientId)!!
val firstSyncMessages = MessageTableTestUtils.getMessages(threadId)
val firstMessageId = firstSyncMessages[0].id
val firstReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
processorV2.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),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)
val secondSyncMessages = MessageTableTestUtils.getMessages(threadId)
val secondReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
firstSyncMessages.size assertIs 1
firstSyncMessages[0].body assertIs initialTextMessage.body
firstReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
firstReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNKNOWN
secondSyncMessages.size assertIs 1
secondSyncMessages[0].body assertIs initialTextMessage.body
secondReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
secondReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
}
}

View file

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.testing
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.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 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 {
return DecryptedMember.newBuilder()
.setUuid(serviceId.toByteString())
.setJoinedAtRevision(revision)
.setRole(role)
.build()
}
fun insertGroup(revision: Int = 0, vararg members: DecryptedMember): TestGroupInfo {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members.toList())
.setRevision(revision)
.setTitle(MessageContentFuzzer.string())
.build()
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)
return TestGroupInfo(groupId, groupMasterKey, groupRecipientId)
}
fun RecipientId.asMember(): DecryptedMember {
return Recipient.resolved(this).asMember()
}
fun Recipient.asMember(): DecryptedMember {
return member(serviceId = requireServiceId())
}
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
}

View file

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.messages.TestMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -12,6 +14,8 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Attach
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import java.util.UUID
import kotlin.random.Random
import kotlin.random.nextInt
@ -41,13 +45,13 @@ object MessageContentFuzzer {
/**
* Create metadata to match an [Envelope].
*/
fun envelopeMetadata(source: RecipientId, destination: RecipientId): EnvelopeMetadata {
fun envelopeMetadata(source: RecipientId, destination: RecipientId, groupId: GroupId.V2? = null): EnvelopeMetadata {
return EnvelopeMetadata(
sourceServiceId = Recipient.resolved(source).requireServiceId(),
sourceE164 = null,
sourceDeviceId = 1,
sealedSender = true,
groupId = null,
groupId = groupId?.decodedId,
destinationServiceId = Recipient.resolved(destination).requireServiceId()
)
}
@ -57,30 +61,60 @@ object MessageContentFuzzer {
* - An expire timer value
* - Bold style body ranges
*/
fun fuzzTextMessage(): Content {
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
body = string()
if (random.nextBoolean()) {
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
}
if (random.nextBoolean()) {
addBodyRanges(
SignalServiceProtos.BodyRange.newBuilder().run {
SignalServiceProtos.BodyRange.newBuilder().buildWith {
start = 0
length = 1
style = SignalServiceProtos.BodyRange.Style.BOLD
build()
}
)
}
build()
if (groupContextV2 != null) {
groupV2 = groupContextV2
}
}
)
.build()
}
/**
* Create a sync sent text message for the given [DataMessage].
*/
fun syncSentTextMessage(
textMessage: DataMessage,
deliveredTo: List<RecipientId>,
recipientUpdate: Boolean = false
): Content {
return Content
.newBuilder()
.setSyncMessage(
SyncMessage.newBuilder().buildWith {
sent = SyncMessage.Sent.newBuilder().buildWith {
timestamp = textMessage.timestamp
message = textMessage
isRecipientUpdate = recipientUpdate
addAllUnidentifiedStatus(
deliveredTo.map {
SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder().buildWith {
destinationUuid = Recipient.resolved(it).requireServiceId().toString()
unidentified = true
}
}
)
}
}
).build()
}
/**
* Create a random media message that may be:
* - A text body
@ -91,7 +125,7 @@ object MessageContentFuzzer {
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextBoolean()) {
body = string()
}
@ -99,24 +133,22 @@ object MessageContentFuzzer {
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
body = string()
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
quote = DataMessage.Quote.newBuilder().buildWith {
id = quoted.envelope.timestamp
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
addAllAttachments(quoted.content.dataMessage.attachmentsList)
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
type = DataMessage.Quote.Type.NORMAL
build()
}
}
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
quote = DataMessage.Quote.newBuilder().buildWith {
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
build()
}
}
@ -124,8 +156,6 @@ object MessageContentFuzzer {
val total = random.nextInt(1, 2)
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
}
build()
}
)
.build()
@ -138,19 +168,16 @@ object MessageContentFuzzer {
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextFloat() < 0.25) {
val reactTo = previousMessages.random(random)
reaction = DataMessage.Reaction.newBuilder().run {
reaction = DataMessage.Reaction.newBuilder().buildWith {
emoji = emojis.random(random)
remove = false
targetAuthorUuid = reactTo.metadata.sourceServiceId.toString()
targetSentTimestamp = reactTo.envelope.timestamp
build()
}
}
build()
}
).build()
}
@ -162,18 +189,16 @@ object MessageContentFuzzer {
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextFloat() < 0.9) {
sticker = DataMessage.Sticker.newBuilder().run {
sticker = DataMessage.Sticker.newBuilder().buildWith {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data = attachmentPointer()
emoji = emojis.random(random)
build()
}
}
build()
}
).build()
}

View file

@ -1,8 +1,21 @@
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
/**
* Helper methods for interacting with [MessageTable] in tests.
*/
object MessageTableTestUtils {
fun getMessages(threadId: Long): List<MessageRecord> {
return MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId)).use {
it.toList()
}
}
object MessageTableUtils {
fun typeColumnToString(type: Long): String {
return """
isOutgoingMessageType:${MessageTypes.isOutgoingMessageType(type)}

View file

@ -570,11 +570,6 @@
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
android:theme="@style/Theme.AppCompat.Dialog.Alert" />
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".contactshare.ContactShareEditActivity"
android:theme="@style/TextSecure.LightTheme"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

File diff suppressed because it is too large Load diff

View file

@ -156,7 +156,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
super.onCreate();
initializeSecurityProvider();
SqlCipherLibraryLoader.load();
EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus();
DynamicTheme.setDefaultDayNightMode(this);
@ -186,6 +185,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
initializeLogging();
Log.i(TAG, "onCreateUnlock()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("app-dependencies", this::initializeAppDependencies)
@ -335,13 +335,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeSecurityProvider() {
try {
Class.forName("org.signal.aesgcmprovider.AesGcmCipher");
} catch (ClassNotFoundException e) {
Log.e(TAG, "Failed to find AesGcmCipher class");
throw new ProviderInitializationException();
}
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);

View file

@ -108,7 +108,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInviteToSignalClicked();
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull ConversationMessage conversationMessage);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);

View file

@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -106,7 +107,9 @@ public class DeviceListFragment extends ListFragment
if (data.isEmpty()) {
empty.setVisibility(View.VISIBLE);
TextSecurePreferences.setMultiDevice(getActivity(), false);
SignalStore.misc().setHasLinkedDevices(false);
} else {
SignalStore.misc().setHasLinkedDevices(true);
empty.setVisibility(View.GONE);
}
}

View file

@ -230,10 +230,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.i(TAG, "onPause");
super.onPause();
if (!isInPipMode() || isFinishing()) {
EventBus.getDefault().unregister(this);
}
if (!viewModel.isCallStarting()) {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
@ -378,6 +374,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode());
if (info.isInPictureInPictureMode()) {
callScreen.maybeDismissAudioPicker();
}
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
});
}
@ -855,7 +855,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@RequiresApi(31)
@Override
public void onAudioOutputChanged31(@NonNull int audioDeviceInfo) {
public void onAudioOutputChanged31(@NonNull Integer audioDeviceInfo) {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
/**
* A listener for when audio devices are added or removed, for example if a wired headset is plugged/unplugged or Bluetooth connected/disconnected.
*/
interface AudioDeviceUpdatedListener {
fun onAudioDeviceUpdated()
}

View file

@ -0,0 +1,139 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.media.AudioDeviceInfo
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.RequiresApi
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
internal const val TAG = "BluetoothVoiceNoteUtil"
sealed interface BluetoothVoiceNoteUtil {
fun connectBluetoothScoConnection()
fun disconnectBluetoothScoConnection()
fun destroy()
companion object {
fun create(context: Context, listener: () -> Unit, bluetoothPermissionDeniedHandler: () -> Unit): BluetoothVoiceNoteUtil {
return if (Build.VERSION.SDK_INT >= 31) BluetoothVoiceNoteUtil31(listener) else BluetoothVoiceNoteUtilLegacy(context, listener, bluetoothPermissionDeniedHandler)
}
}
}
@RequiresApi(31)
private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoiceNoteUtil {
override fun connectBluetoothScoConnection() {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
}
override fun disconnectBluetoothScoConnection() = Unit
override fun destroy() = Unit
}
/**
* Encapsulated logic for managing a Bluetooth connection withing the Fragment lifecycle for voice notes.
*
* @param context Context with reference to the main thread.
* @param listener This will be executed on the main thread after the Bluetooth connection connects, or if it doesn't.
* @param bluetoothPermissionDeniedHandler called when we detect the Bluetooth permission has been denied to our app.
*/
private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: () -> Unit, val bluetoothPermissionDeniedHandler: () -> Unit) : BluetoothVoiceNoteUtil {
private val commandAndControlThread: HandlerThread = SignalExecutors.getAndStartHandlerThread("voice-note-audio", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
private val uiThreadHandler = Handler(context.mainLooper)
private val audioHandler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread.looper)
private val deviceUpdatedListener: AudioDeviceUpdatedListener = object : AudioDeviceUpdatedListener {
override fun onAudioDeviceUpdated() {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
Log.d(TAG, "Bluetooth SCO connected. Starting voice note recording on UI thread.")
uiThreadHandler.post { listener() }
}
}
}
private val signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, deviceUpdatedListener, audioHandler)
private var hasWarnedAboutBluetooth = false
init {
if (Build.VERSION.SDK_INT < 31) {
audioHandler.post {
signalBluetoothManager.start()
Log.d(TAG, "Bluetooth manager started.")
}
}
}
override fun connectBluetoothScoConnection() {
if (Build.VERSION.SDK_INT >= 31) {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
} else {
audioHandler.post {
if (signalBluetoothManager.state.shouldUpdate()) {
signalBluetoothManager.updateDevice()
}
val currentState = signalBluetoothManager.state
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
signalBluetoothManager.startScoAudio()
} else {
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
uiThreadHandler.post {
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
bluetoothPermissionDeniedHandler()
hasWarnedAboutBluetooth = true
}
listener()
}
}
}
}
}
override fun disconnectBluetoothScoConnection() {
audioHandler.post {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
signalBluetoothManager.stopScoAudio()
}
}
}
override fun destroy() {
audioHandler.post {
signalBluetoothManager.stop()
}
}
}

View file

@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.webrtc.audio
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@ -13,18 +18,19 @@ import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit
/**
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
* determination on if bluetooth should be used. It determines if a device is connected,
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [SignalAudioManager].
* reports that to the [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager].
*/
@SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155)
class SignalBluetoothManager(
private val context: Context,
private val audioManager: FullSignalAudioManager,
private val audioDeviceUpdatedListener: AudioDeviceUpdatedListener,
private val handler: SignalAudioHandler
) {
@ -139,11 +145,6 @@ class SignalBluetoothManager(
return false
}
if (androidAudioManager.isBluetoothScoOn) {
Log.i(TAG, "SCO connection already started")
return true
}
state = State.CONNECTING
androidAudioManager.startBluetoothSco()
androidAudioManager.isBluetoothScoOn = true
@ -202,10 +203,6 @@ class SignalBluetoothManager(
}
}
private fun updateAudioDeviceState() {
audioManager.updateAudioDeviceState()
}
private fun startTimer() {
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
}
@ -243,12 +240,12 @@ class SignalBluetoothManager(
stopScoAudio()
}
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onServiceConnected(proxy: BluetoothHeadset?) {
bluetoothHeadset = proxy
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onServiceDisconnected() {
@ -256,7 +253,7 @@ class SignalBluetoothManager(
bluetoothHeadset = null
bluetoothDevice = null
state = State.UNAVAILABLE
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
@ -265,12 +262,12 @@ class SignalBluetoothManager(
when (connectionState) {
BluetoothHeadset.STATE_CONNECTED -> {
scoConnectionAttempts = 0
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
BluetoothHeadset.STATE_DISCONNECTED -> {
stopScoAudio()
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
}
@ -284,7 +281,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Bluetooth audio SCO is now connected")
state = State.CONNECTED
scoConnectionAttempts = 0
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
} else {
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
}
@ -296,7 +293,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
return
}
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
@ -347,7 +344,9 @@ class SignalBluetoothManager(
}
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
if (wasScoDisconnected(intent)) {
handler.post(::updateAudioDeviceState)
handler.post {
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
} else {
Log.d(TAG, "Received broadcast of ${intent.action}")

View file

@ -11,7 +11,6 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import net.zetetic.database.sqlcipher.SQLiteConstraintException;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;

View file

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.calls.links
/**
* Utility object for call links to try to keep some common logic in one place.
*/
object CallLinks {
fun url(identifier: String) = "https://calls.signal.org/#$identifier"
}

View file

@ -28,18 +28,21 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
companion object {
const val RESULT_KEY = "edit_call_link_name"
}
private val args: EditCallLinkNameDialogFragmentArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -57,12 +60,11 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Preview
@Composable
override fun DialogContent() {
val viewModelCallName by viewModel.callName
var callName by remember {
mutableStateOf(
TextFieldValue(
text = viewModelCallName,
selection = TextRange(viewModelCallName.length)
text = args.name,
selection = TextRange(args.name.length)
)
)
}
@ -97,7 +99,7 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
viewModel.setCallName(callName.text)
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
dismiss()
},
modifier = Modifier.align(End)

View file

@ -15,12 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@ -29,14 +31,25 @@ import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
import org.thoughtcrime.securesms.database.CallLinkTable
@Preview
@Composable
private fun SignalCallRowPreview() {
val avatarColor = remember { AvatarColor.random() }
val callLink = remember {
CallLinkTable.CallLink(
name = "Call Name",
identifier = "blahblahblah",
avatarColor = avatarColor,
isApprovalRequired = false
)
}
SignalTheme(false) {
SignalCallRow(
callName = "Call Name",
callLink = "https://call.signal.org#blahblahblah",
callLink = callLink,
onJoinClicked = {}
)
}
@ -44,8 +57,7 @@ private fun SignalCallRowPreview() {
@Composable
fun SignalCallRow(
callName: String,
callLink: String,
callLink: CallLinkTable.CallLink,
onJoinClicked: () -> Unit,
modifier: Modifier = Modifier
) {
@ -60,15 +72,17 @@ fun SignalCallRow(
)
.padding(16.dp)
) {
val callColorPair = AvatarColorPair.create(LocalContext.current, callLink.avatarColor)
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
colorFilter = ColorFilter.tint(Color(callColorPair.foregroundColor)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
color = Color(callColorPair.backgroundColor),
shape = CircleShape
)
)
@ -81,10 +95,10 @@ fun SignalCallRow(
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
text = callLink.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
text = CallLinks.url(callLink.identifier),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View file

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.calls.links.create
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -25,15 +27,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.thoughtcrime.securesms.R
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.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.Util
@ -46,6 +51,14 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
override val peekHeightPercentage: Float = 1f
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setCallName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun SheetContent() {
Column(
@ -53,9 +66,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
) {
val callName: String by viewModel.callName
val callLink: String by viewModel.callLink
val approveAllMembers: Boolean by viewModel.approveAllMembers
val callLink: CallLinkTable.CallLink by viewModel.callLink
Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
@ -71,7 +82,6 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
Spacer(modifier = Modifier.height(24.dp))
SignalCallRow(
callName = callName,
callLink = callLink,
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
)
@ -84,7 +94,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
)
Rows.ToggleRow(
checked = approveAllMembers,
checked = callLink.isApprovalRequired,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
@ -124,7 +134,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
private fun onAddACallNameClicked() {
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
val snapshot = viewModel.callLink.value
findNavController().navigate(
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.name)
)
}
private fun onJoinClicked() {
@ -142,7 +155,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot)
.withDraftText(snapshot.identifier)
.build()
)
)
@ -151,7 +164,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), snapshot)
Util.copyToClipboard(requireContext(), CallLinks.url(snapshot.identifier))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
@ -159,7 +172,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(snapshot)
.setText(CallLinks.url(snapshot.identifier))
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

View file

@ -4,29 +4,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
class CreateCallLinkViewModel : ViewModel() {
private val _callName: MutableState<String> = mutableStateOf("")
private val _callLink: MutableState<String> = mutableStateOf("")
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
val callName: State<String> = _callName
val callLink: State<String> = _callLink
val approveAllMembers: State<Boolean> = _approveAllMembers
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.random(), false)
)
val callLink: State<CallLinkTable.CallLink> = _callLink
fun setApproveAllMembers(approveAllMembers: Boolean) {
_approveAllMembers.value = approveAllMembers
_callLink.value = _callLink.value.copy(isApprovalRequired = approveAllMembers)
}
fun toggleApproveAllMembers() {
_approveAllMembers.value = !_approveAllMembers.value
_callLink.value = _callLink.value.copy(isApprovalRequired = _callLink.value.isApprovalRequired)
}
fun setCallName(callName: String) {
_callName.value = callName
}
fun setCallLink(callLink: String) {
_callLink.value = callLink
_callLink.value = _callLink.value.copy(name = callName)
}
}

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details)
}

View file

@ -0,0 +1,179 @@
package org.thoughtcrime.securesms.calls.links.details
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
/**
* Provides detailed info about a call link and allows the owner of that link
* to modify call properties.
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
private val viewModel: CallLinkViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun FragmentContent() {
val isLoading by viewModel.isLoading
val callLink by viewModel.callLink
CallLinkDetails(
isLoading,
callLink,
this
)
}
override fun onNavigationClicked() {
findNavController().popBackStack()
}
override fun onJoinClicked() {
// TODO("Not yet implemented")
}
override fun onEditNameClicked() {
val name = viewModel.callLink.value.name
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
// TODO("Not yet implemented")
}
override fun onDeleteClicked() {
// TODO("Not yet implemented")
}
override fun onApproveAllMembersChanged(checked: Boolean) {
// TODO("Not yet implemented")
}
}
private interface CallLinkDetailsCallback {
fun onNavigationClicked()
fun onJoinClicked()
fun onEditNameClicked()
fun onShareClicked()
fun onDeleteClicked()
fun onApproveAllMembersChanged(checked: Boolean)
}
@Preview
@Composable
private fun CallLinkDetailsPreview() {
val avatarColor = remember {
AvatarColor.random()
}
val callLink = remember {
CallLinkTable.CallLink(
name = "Call Name",
identifier = "call-id-1",
isApprovalRequired = false,
avatarColor = avatarColor
)
}
SignalTheme(false) {
CallLinkDetails(
false,
callLink,
object : CallLinkDetailsCallback {
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
override fun onShareClicked() = Unit
override fun onDeleteClicked() = Unit
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
}
)
}
}
@Composable
private fun CallLinkDetails(
isLoading: Boolean,
callLink: CallLinkTable.CallLink,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
onNavigationClick = callback::onNavigationClicked,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
) { paddingValues ->
if (isLoading) {
return@Settings
}
Column(modifier = Modifier.padding(paddingValues)) {
SignalCallRow(
callLink = callLink,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
modifier = Modifier.clickable(onClick = callback::onEditNameClicked)
)
Rows.ToggleRow(
checked = callLink.isApprovalRequired,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
modifier = Modifier.clickable(onClick = callback::onShareClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
)
}
}
}

View file

@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
class CallLinkViewModel : ViewModel() {
private val isLoadingState: MutableState<Boolean> = mutableStateOf(true)
val isLoading: State<Boolean> = isLoadingState
private val callLinkState: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.A120, false)
)
val callLink: State<CallLinkTable.CallLink> = callLinkState
fun setName(name: String) {
callLinkState.value = callLinkState.value.copy(name = name)
}
}

View file

@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
@ -14,11 +17,14 @@ import androidx.annotation.Nullable;
import com.airbnb.lottie.SimpleColorFilter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class ConversationScrollToView extends FrameLayout {
private final TextView unreadCount;
private final ImageView scrollButton;
private final Animation inAnimation;
private final Animation outAnimation;
public ConversationScrollToView(@NonNull Context context) {
this(context, null);
@ -44,6 +50,20 @@ public final class ConversationScrollToView extends FrameLayout {
array.recycle();
}
inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in);
outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out);
inAnimation.setDuration(100);
outAnimation.setDuration(50);
}
public void setShown(boolean isShown) {
if (isShown) {
ViewUtil.animateIn(this, inAnimation);
} else {
ViewUtil.animateOut(this, outAnimation, View.INVISIBLE);
}
}
public void setWallpaperEnabled(boolean hasWallpaper) {

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components
import android.view.View
import androidx.annotation.AnyThread
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@ -36,7 +37,6 @@ class ScrollToPositionDelegate private constructor(
private val EMPTY = ScrollToPositionRequest(
position = NO_POSITION,
smooth = true,
awaitLayout = true,
scrollStrategy = DefaultScrollStrategy
)
}
@ -57,14 +57,8 @@ class ScrollToPositionDelegate private constructor(
.filter { it.position >= 0 && canJumpToPosition(it.position) }
.map { it.copy(position = mapToTruePosition(it.position)) }
.subscribeBy(onNext = { position ->
if (position.awaitLayout) {
recyclerView.doAfterNextLayout {
handleScrollPositionRequest(position, recyclerView)
}
} else {
recyclerView.post {
handleScrollPositionRequest(position, recyclerView)
}
recyclerView.doAfterNextLayout {
handleScrollPositionRequest(position, recyclerView)
}
if (!(recyclerView.isLayoutRequested || recyclerView.isInLayout)) {
@ -78,21 +72,21 @@ class ScrollToPositionDelegate private constructor(
*
* @param position The desired position to jump to. -1 to clear the current request.
* @param smooth Whether a smooth scroll will be attempted. Only done if we are within a certain distance.
* @param awaitLayout Whether this scroll should await for the next layout to complete before being attempted.
* @param scrollStrategy See [ScrollStrategy]
*/
@AnyThread
fun requestScrollPosition(
position: Int,
smooth: Boolean = true,
awaitLayout: Boolean = true,
scrollStrategy: ScrollStrategy = DefaultScrollStrategy
) {
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, awaitLayout, scrollStrategy))
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, scrollStrategy))
}
/**
* Reset the scroll position to 0
*/
@AnyThread
fun resetScrollPosition() {
requestScrollPosition(0, true)
}
@ -100,6 +94,7 @@ class ScrollToPositionDelegate private constructor(
/**
* This should be called every time a list is submitted to the RecyclerView's adapter.
*/
@AnyThread
fun notifyListCommitted() {
listCommitted.onNext(Unit)
}
@ -135,7 +130,6 @@ class ScrollToPositionDelegate private constructor(
private data class ScrollToPositionRequest(
val position: Int,
val smooth: Boolean,
val awaitLayout: Boolean,
val scrollStrategy: ScrollStrategy
)

View file

@ -13,7 +13,7 @@ import com.google.zxing.common.BitMatrix;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SquareImageView;
import org.thoughtcrime.securesms.qr.QrCode;
import org.thoughtcrime.securesms.qr.QrCodeUtil;
/**
* Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it.
@ -59,7 +59,7 @@ public class QrView extends SquareImageView {
}
public void setQrText(@Nullable String text) {
setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor));
setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor));
}
private void setQrBitmap(@Nullable Bitmap qrBitmap) {

View file

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@ -9,12 +11,14 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class UnauthorizedReminder extends Reminder {
public UnauthorizedReminder(final Context context) {
super(context.getString(R.string.UnauthorizedReminder_device_no_longer_registered),
super(null,
context.getString(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device));
setOkListener(v -> {
context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context));
});
addAction(new Action(context.getString(R.string.UnauthorizedReminder_reregister_action), R.id.reminder_action_re_register));
}
@Override
@ -22,6 +26,11 @@ public class UnauthorizedReminder extends Reminder {
return false;
}
@Override
public @NonNull Importance getImportance() {
return Importance.ERROR;
}
public static boolean isEligible(Context context) {
return TextSecurePreferences.isUnauthorizedReceived(context);
}

View file

@ -64,6 +64,7 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
val icon = model.icon?.resolve(context)
iconView.setImageDrawable(icon)
iconView.visible = icon != null
iconView.alpha = if (model.isEnabled) 1f else 0.5f
val iconEnd = model.iconEnd?.resolve(context)
iconEndView?.setImageDrawable(iconEnd)

View file

@ -1,12 +1,21 @@
package org.thoughtcrime.securesms.components.settings.app
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.annotation.IdRes
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.components.reminder.ReminderView
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
@ -14,19 +23,36 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.Stub
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
class AppSettingsFragment : DSLSettingsFragment(
titleId = R.string.text_secure_normal__menu_settings,
layoutId = R.layout.dsl_settings_fragment_with_reminder
) {
private val viewModel: AppSettingsViewModel by viewModels()
private lateinit var reminderView: Stub<ReminderView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)
updateReminders()
}
override fun bindAdapter(adapter: MappingAdapter) {
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
@ -36,12 +62,77 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: ReminderUpdateEvent?) {
updateReminders()
}
private fun updateReminders() {
if (ExpiredBuildReminder.isEligible()) {
showReminder(ExpiredBuildReminder(context))
} else if (UnauthorizedReminder.isEligible(context)) {
showReminder(UnauthorizedReminder(context))
} else {
hideReminders()
}
viewModel.refreshDeprecatedOrUnregistered()
}
private fun showReminder(reminder: Reminder) {
if (!reminderView.resolved()) {
reminderView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
recyclerView?.setPadding(0, bottom - top, 0, 0)
}
recyclerView?.clipToPadding = false
}
reminderView.get().showReminder(reminder)
reminderView.get().setOnActionClickListener { reminderActionId: Int -> this.handleReminderAction(reminderActionId) }
}
private fun hideReminders() {
if (reminderView.resolved()) {
reminderView.get().hide()
recyclerView?.clipToPadding = true
}
}
private fun handleReminderAction(@IdRes reminderActionId: Int) {
when (reminderActionId) {
R.id.reminder_action_update_now -> {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
R.id.reminder_action_re_register -> {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
}
}
override fun onResume() {
super.onResume()
EventBus.getDefault().register(this)
}
override fun onPause() {
super.onPause()
EventBus.getDefault().unregister(this)
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
customPref(
BioPreference(state.self) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}
BioPreference(
recipient = state.self,
onRowClicked = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
},
onQrButtonClicked = {
if (Recipient.self().getUsername().isPresent()) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
}
}
)
)
clickPref(
@ -57,7 +148,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
externalLinkPref(
@ -81,7 +173,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_chat_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@ -89,7 +182,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@ -97,7 +191,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_bell_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@ -105,7 +200,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_lock_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@ -167,7 +263,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
override fun areContentsTheSame(newItem: BioPreference): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
@ -182,11 +278,12 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
private val aboutView: TextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val qrButton: View = itemView.findViewById(R.id.qr_button)
override fun bind(model: BioPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
itemView.setOnClickListener { model.onRowClicked() }
titleView.text = model.recipient.profileName.toString()
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
@ -197,6 +294,14 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
summaryView.visibility = View.VISIBLE
avatarView.visibility = View.VISIBLE
if (FeatureFlags.usernames()) {
qrButton.visibility = View.VISIBLE
qrButton.isClickable = true
qrButton.setOnClickListener { model.onQrButtonClicked() }
} else {
qrButton.visibility = View.GONE
}
if (model.recipient.combinedAboutAndEmoji != null) {
aboutView.text = model.recipient.combinedAboutAndEmoji
aboutView.visibility = View.VISIBLE

View file

@ -5,4 +5,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
)
val userUnregistered: Boolean,
val clientDeprecated: Boolean
) {
fun isDeprecatedOrUnregistered(): Boolean {
return !(userUnregistered || clientDeprecated)
}
}

View file

@ -4,7 +4,10 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class AppSettingsViewModel : ViewModel() {
@ -13,6 +16,8 @@ class AppSettingsViewModel : ViewModel() {
AppSettingsState(
Recipient.self(),
0,
TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
SignalStore.misc().isClientDeprecated
)
)
@ -30,4 +35,8 @@ class AppSettingsViewModel : ViewModel() {
override fun onCleared() {
disposables.clear()
}
fun refreshDeprecatedOrUnregistered() {
store.update { it.copy(clientDeprecated = SignalStore.misc().isClientDeprecated, userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())) }
}
}

View file

@ -1,18 +1,25 @@
package org.thoughtcrime.securesms.components.settings.app.account
import android.content.DialogInterface
import android.content.Intent
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@ -46,6 +53,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
if (state.hasPin) {
startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN)
@ -59,7 +67,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
isChecked = state.hasPin && state.pinRemindersEnabled,
isEnabled = state.hasPin,
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
onClick = {
setPinRemindersEnabled(!state.pinRemindersEnabled)
}
@ -69,7 +77,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
isChecked = state.registrationLockEnabled,
isEnabled = state.hasPin,
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
onClick = {
setRegistrationLockEnabled(!state.registrationLockEnabled)
}
@ -77,6 +85,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences__advanced_pin_settings),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
}
@ -89,6 +98,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
if (SignalStore.account().isRegistered) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
}
@ -98,6 +108,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
}
@ -105,13 +116,49 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
}
)
if (!state.isDeprecatedOrUnregistered()) {
if (state.clientDeprecated) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_update_signal),
onClick = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
)
} else if (state.userUnregistered) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_reregister),
onClick = {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_delete_all_data, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.preferences_account_delete_all_data_confirmation_title)
.setMessage(R.string.preferences_account_delete_all_data_confirmation_message)
.setPositiveButton(R.string.preferences_account_delete_all_data_confirmation_proceed) { _: DialogInterface, _: Int ->
if (!ServiceUtil.getActivityManager(ApplicationDependencies.getApplication()).clearApplicationUserData()) {
Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
}
}
.setNegativeButton(R.string.preferences_account_delete_all_data_confirmation_cancel, null)
.show()
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), if (state.isDeprecatedOrUnregistered()) R.color.signal_alert_primary else R.color.signal_alert_primary_50)),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment)
}

View file

@ -3,5 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.account
data class AccountSettingsState(
val hasPin: Boolean,
val pinRemindersEnabled: Boolean,
val registrationLockEnabled: Boolean
)
val registrationLockEnabled: Boolean,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
) {
fun isDeprecatedOrUnregistered(): Boolean {
return !(userUnregistered || clientDeprecated)
}
}

View file

@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.account
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class AccountSettingsViewModel : ViewModel() {
@ -18,7 +20,9 @@ class AccountSettingsViewModel : ViewModel() {
return AccountSettingsState(
hasPin = SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut(),
pinRemindersEnabled = SignalStore.pinValues().arePinRemindersEnabled(),
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
clientDeprecated = SignalStore.misc().isClientDeprecated
)
}
}

View file

@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.Context
import android.content.DialogInterface.OnClickListener
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.LifecycleDisposable
@ -46,6 +50,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
requestCode()
} else {
Log.d(TAG, "Captcha required.")
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
}
@ -60,6 +65,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
.andThen(viewModel.changeNumberWithRecoveryPassword())
.flatMap { changed ->
if (changed) {
Log.d(TAG, "Successfully changed number using recovery password.")
Single.just(RequestCodeResult.RecoveryPasswordWorked)
} else {
viewModel.requestVerificationCode(mode, mccMncProducer.mcc, mccMncProducer.mnc)
@ -83,16 +89,18 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
requestingCaptcha = true
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit")
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
showErrorDialog(requireContext(), R.string.RegistrationActivity_rate_limited_to_service) { _, _ -> findNavController().navigateUp() }
} else {
Log.w(TAG, "Unable to request sms code", processor.error)
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
showErrorDialog(requireContext(), R.string.RegistrationActivity_unable_to_request_verification_code) { _, _ -> findNavController().navigateUp() }
}
}
}
private fun showErrorDialog(context: Context, @StringRes message: Int, onPositiveButtonClickListener: OnClickListener?) {
MaterialAlertDialogBuilder(context).setMessage(message).setPositiveButton(android.R.string.ok, onPositiveButtonClickListener).show()
}
private sealed interface RequestCodeResult {
object RecoveryPasswordWorked : RequestCodeResult
class RequestedVerificationCode(val processor: RegistrationSessionProcessor) : RequestCodeResult

View file

@ -0,0 +1,165 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.R
/**
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
*/
@Composable
fun QrCode(
data: QrCodeData,
modifier: Modifier = Modifier,
foregroundColor: Color = Color.Black,
backgroundColor: Color = Color.White,
deadzonePercent: Float = 0.4f
) {
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
Column(
modifier = modifier
.drawBehind {
drawQr(
data = data,
foregroundColor = foregroundColor,
backgroundColor = backgroundColor,
deadzonePercent = deadzonePercent,
logo = logo
)
}
) {
}
}
private fun DrawScope.drawQr(
data: QrCodeData,
foregroundColor: Color,
backgroundColor: Color,
deadzonePercent: Float,
logo: ImageBitmap
) {
// We want an even number of dots on either side of the deadzone
val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt()
val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) {
candidateDeadzoneWidth
} else {
candidateDeadzoneWidth + 1
}
val candidateDeadzoneHeight: Int = (data.height * deadzonePercent).toInt()
val deadzoneHeight: Int = if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
candidateDeadzoneHeight
} else {
candidateDeadzoneHeight + 1
}
val deadzoneStartX: Int = (data.width - deadzoneWidth) / 2
val deadzoneEndX: Int = deadzoneStartX + deadzoneWidth
val deadzoneStartY: Int = (data.height - deadzoneHeight) / 2
val deadzoneEndY: Int = deadzoneStartY + deadzoneHeight
val cellWidthPx: Float = size.width / data.width
val cellRadiusPx = cellWidthPx / 2
for (x in 0 until data.width) {
for (y in 0 until data.height) {
if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) {
drawCircle(
color = if (data.get(x, y)) foregroundColor else backgroundColor,
radius = cellRadiusPx,
center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx)
)
}
}
}
// Logo border
val deadzonePaddingPercent = 0.02f
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle(
color = foregroundColor,
radius = logoBorderRadiusPx,
style = Stroke(width = cellWidthPx * 0.7f),
center = this.center
)
// Logo
val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
)
for (eye in data.eyes()) {
val strokeWidth = cellWidthPx
// Clear the already-drawn dots
drawRect(
color = backgroundColor,
topLeft = Offset(
x = eye.position.first * cellWidthPx,
y = eye.position.second * cellWidthPx
),
size = Size(eye.size * cellWidthPx + cellRadiusPx, eye.size * cellWidthPx)
)
// Outer square
drawRoundRect(
color = foregroundColor,
topLeft = Offset(
x = eye.position.first * cellWidthPx + strokeWidth / 2,
y = eye.position.second * cellWidthPx + strokeWidth / 2
),
size = Size((eye.size - 1) * cellWidthPx, (eye.size - 1) * cellWidthPx),
cornerRadius = CornerRadius(cellRadiusPx * 2, cellRadiusPx * 2),
style = Stroke(width = strokeWidth)
)
// Inner square
drawRoundRect(
color = foregroundColor,
topLeft = Offset(
x = (eye.position.first + 2) * cellWidthPx,
y = (eye.position.second + 2) * cellWidthPx
),
size = Size((eye.size - 4) * cellWidthPx, (eye.size - 4) * cellWidthPx),
cornerRadius = CornerRadius(cellRadiusPx, cellRadiusPx)
)
}
}
@Preview
@Composable
private fun Preview() {
Surface {
QrCode(
data = QrCodeData.forData("https://signal.org", 64),
modifier = Modifier
.width(100.dp)
.height(100.dp),
deadzonePercent = 0.3f
)
}
}

View file

@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme
/**
* Renders a QR code and username as a badge.
*/
@Composable
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) {
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 59.dp, vertical = 24.dp),
color = borderColor,
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation.dp
) {
Column {
Surface(
modifier = Modifier
.padding(
top = 32.dp,
start = 40.dp,
end = 40.dp,
bottom = 16.dp
)
.aspectRatio(1f)
.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Color.White
) {
if (data != null) {
QrCode(
data = data,
modifier = Modifier.padding(20.dp),
foregroundColor = foregroundColor,
backgroundColor = Color.White
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = colorScheme.borderColor,
modifier = Modifier.size(56.dp)
)
}
}
}
Text(
text = username,
color = textColor,
fontSize = 20.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 40.dp,
end = 40.dp,
bottom = 32.dp
)
)
}
}
}
@Preview
@Composable
private fun PreviewWithCode() {
SignalTheme(isDarkMode = false) {
Surface {
QrCodeBadge(
data = QrCodeData.forData("https://signal.org", 64),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)
}
}
}
@Preview
@Composable
private fun PreviewWithoutCode() {
SignalTheme(isDarkMode = false) {
Surface {
QrCodeBadge(
data = null,
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)
}
}
}

View file

@ -0,0 +1,138 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.annotation.WorkerThread
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import java.util.BitSet
/**
* Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner.
* X increases as you move right, and Y increases as you go down.
*/
class QrCodeData(
val width: Int,
val height: Int,
private val bits: BitSet
) {
fun get(x: Int, y: Int): Boolean {
return bits.get(y * width + x)
}
/**
* Returns the position of the "eyes" of the QR code -- the big squares in the three corners.
*/
fun eyes(): List<Eye> {
val eyes: MutableList<Eye> = mutableListOf()
val size: Int = getPossibleEyeSize()
// Top left
if (
horizontalLineExists(0, 0, size) &&
horizontalLineExists(0, size - 1, size) &&
verticalLineExists(0, 0, size) &&
verticalLineExists(size - 1, 0, size)
) {
eyes += Eye(
position = 0 to 0,
size = size
)
}
// Bottom left
if (
horizontalLineExists(0, height - size, size) &&
horizontalLineExists(0, size - 1, size) &&
verticalLineExists(0, height - size, size) &&
verticalLineExists(size - 1, height - size, size)
) {
eyes += Eye(
position = 0 to height - size,
size = size
)
}
// Top right
if (
horizontalLineExists(width - size, 0, size) &&
horizontalLineExists(width - size, size - 1, size) &&
verticalLineExists(width - size, 0, size) &&
verticalLineExists(width - 1, 0, size)
) {
eyes += Eye(
position = width - size to 0,
size = size
)
}
return eyes
}
private fun getPossibleEyeSize(): Int {
var x = 0
while (get(x, 0)) {
x++
}
return x
}
private fun horizontalLineExists(x: Int, y: Int, length: Int): Boolean {
for (p in x until x + length) {
if (!get(p, y)) {
return false
}
}
return true
}
private fun verticalLineExists(x: Int, y: Int, length: Int): Boolean {
for (p in y until y + length) {
if (!get(x, p)) {
return false
}
}
return true
}
data class Eye(
val position: Pair<Int, Int>,
val size: Int
)
companion object {
/**
* Converts the provided string data into a QR representation.
*/
@WorkerThread
fun forData(data: String, size: Int): QrCodeData {
val qrCodeWriter = QRCodeWriter()
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val dimens = padded.enclosingRectangle
val xStart = dimens[0]
val yStart = dimens[1]
val width = dimens[2]
val height = dimens[3]
val bitSet = BitSet(width * height)
for (x in xStart until xStart + width) {
for (y in yStart until yStart + height) {
if (padded.get(x, y)) {
val destX = x - xStart
val destY = y - yStart
bitSet.set(destY * width + destX)
}
}
}
return QrCodeData(width, height, bitSet)
}
}
}

View file

@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.ui.graphics.Color
/**
* A set of color schemes for sharing QR codes.
*/
enum class UsernameQrCodeColorScheme(
val borderColor: Color,
val foregroundColor: Color,
private val key: String
) {
Blue(
borderColor = Color(0xFF506ECD),
foregroundColor = Color(0xFF2449C0),
key = "blue"
),
White(
borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF464852),
key = "white"
),
Grey(
borderColor = Color(0xFF6A6C74),
foregroundColor = Color(0xFF464852),
key = "grey"
),
Tan(
borderColor = Color(0xFFBBB29A),
foregroundColor = Color(0xFF73694F),
key = "tan"
),
Green(
borderColor = Color(0xFF97AA89),
foregroundColor = Color(0xFF55733F),
key = "green"
),
Orange(
borderColor = Color(0xFFDE7134),
foregroundColor = Color(0xFFDA6C2E),
key = "orange"
),
Pink(
borderColor = Color(0xFFEA7B9D),
foregroundColor = Color(0xFFBB617B),
key = "pink"
),
Purple(
borderColor = Color(0xFF9E7BE9),
foregroundColor = Color(0xFF7651C5),
key = "purple"
);
fun serialize(): String {
return key
}
companion object {
/**
* Returns the [UsernameQrCodeColorScheme] based on the serialized string. If no match is found, the default of [Blue] is returned.
*/
@JvmStatic
fun deserialize(serialized: String?): UsernameQrCodeColorScheme {
return values().firstOrNull { it.key == serialized } ?: Blue
}
}
}

View file

@ -0,0 +1,187 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.compose.ComposeFragment
/**
* Gives the user the ability to change the color of their shareable username QR code with a live preview.
*/
@OptIn(ExperimentalMaterial3Api::class)
class UsernameLinkQrColorPickerFragment : ComposeFragment() {
val viewModel: UsernameLinkQrColorPickerViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state: UsernameLinkQrColorPickerState by viewModel.state
val navController: NavController by remember { mutableStateOf(findNavController()) }
Scaffold(
topBar = { TopAppBarContent(onBackClicked = { navController.popBackStack() }) }
) { contentPadding ->
Column(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
QrCodeBadge(
data = state.qrCodeData,
colorScheme = state.selectedColorScheme,
username = state.username
)
ColorPicker(
colors = state.colorSchemes,
selected = state.selectedColorScheme,
onSelectionChanged = { color -> viewModel.onColorSelected(color) }
)
Row(
modifier = Modifier
.weight(1f, false)
.fillMaxWidth()
.padding(end = 24.dp),
horizontalArrangement = Arrangement.End
) {
Buttons.MediumTonal(onClick = { navController.popBackStack() }) {
Text(stringResource(R.string.UsernameLinkSettings_done_button_label))
}
}
}
}
}
@Composable
private fun TopAppBarContent(onBackClicked: () -> Unit) {
TopAppBar(
title = {
Text(stringResource(R.string.UsernameLinkSettings_color_picker_app_bar_title))
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Image(painter = painterResource(R.drawable.symbol_arrow_left_24), contentDescription = null)
}
}
)
}
@Composable
private fun ColorPicker(colors: ImmutableList<UsernameQrCodeColorScheme>, selected: UsernameQrCodeColorScheme, onSelectionChanged: (UsernameQrCodeColorScheme) -> Unit) {
LazyVerticalGrid(
modifier = Modifier.padding(horizontal = 30.dp),
columns = GridCells.Adaptive(minSize = 88.dp)
) {
colors.forEach { color ->
item(key = color.serialize()) {
ColorPickerItem(
color = color,
selected = color == selected,
onClick = {
onSelectionChanged(color)
}
)
}
}
}
}
@Composable
private fun ColorPickerItem(color: UsernameQrCodeColorScheme, selected: Boolean, onClick: () -> Unit) {
val outerBorderColor by animateColorAsState(targetValue = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent)
val colorCircleSize by animateFloatAsState(targetValue = if (selected) 44f else 56f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 13.dp)
.border(width = 2.dp, color = outerBorderColor, shape = CircleShape)
.size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Surface(
onClick = onClick,
modifier = Modifier
.border(width = 2.dp, color = Color.Black.copy(alpha = 0.12f), shape = CircleShape)
.size(colorCircleSize.dp),
shape = CircleShape,
color = color.borderColor,
content = {}
)
}
}
}
@Preview
@Composable
private fun ColorPickerItemPreview() {
SignalTheme(isDarkMode = false) {
Surface {
Row(verticalAlignment = Alignment.CenterVertically) {
ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = false, onClick = {})
ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = true, onClick = {})
}
}
}
}
@Preview
@Composable
private fun ColorPickerPreview() {
SignalTheme(isDarkMode = false) {
Surface {
ColorPicker(
colors = UsernameQrCodeColorScheme.values().toList().toImmutableList(),
selected = UsernameQrCodeColorScheme.Blue,
onSelectionChanged = {}
)
}
}
}
}

View file

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
import kotlinx.collections.immutable.ImmutableList
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
data class UsernameLinkQrColorPickerState(
val username: String,
val qrCodeData: QrCodeData?,
val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>,
val selectedColorScheme: UsernameQrCodeColorScheme
)

View file

@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
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
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.collections.immutable.toImmutableList
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkQrColorPickerViewModel : ViewModel() {
private val username: String = Recipient.self().username.get()
private val _state = mutableStateOf(
UsernameLinkQrColorPickerState(
username = username,
qrCodeData = null,
colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(),
selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
)
val state: State<UsernameLinkQrColorPickerState> = _state
private val disposable: CompositeDisposable = CompositeDisposable()
init {
disposable += Single
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
_state.value = _state.value.copy(
qrCodeData = qrData
)
}
}
override fun onCleared() {
disposable.clear()
}
fun onColorSelected(color: UsernameQrCodeColorScheme) {
SignalStore.misc().usernameQrCodeColorScheme = color
_state.value = _state.value.copy(
selectedColorScheme = color
)
}
}

View file

@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.tooling.preview.Preview
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.CoroutineScope
import org.thoughtcrime.securesms.compose.ComposeFragment
@OptIn(ExperimentalMaterial3Api::class)
class UsernameLinkSettingsFragment : ComposeFragment() {
val viewModel: UsernameLinkSettingsViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
val scope: CoroutineScope = rememberCoroutineScope()
val navController: NavController by remember { mutableStateOf(findNavController()) }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { contentPadding ->
UsernameLinkShareScreen(
state = state,
snackbarHostState = snackbarHostState,
scope = scope,
contentPadding = contentPadding,
navController = navController
)
}
}
override fun onResume() {
super.onResume()
viewModel.onResume()
}
@Preview
@Composable
fun PreviewAll() {
FragmentContent()
}
}

View file

@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
/**
* Represents the UI state of the [UsernameLinkSettingsFragment].
*/
data class UsernameLinkSettingsState(
val username: String,
val usernameLink: String,
val qrCodeData: QrCodeData?,
val qrCodeColorScheme: UsernameQrCodeColorScheme
)

View file

@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
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
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkSettingsViewModel : ViewModel() {
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
private val _state = mutableStateOf(
UsernameLinkSettingsState(
username = username.value!!,
usernameLink = UsernameUtil.generateLink(username.value!!),
qrCodeData = null,
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
)
val state: State<UsernameLinkSettingsState> = _state
private val disposable: CompositeDisposable = CompositeDisposable()
init {
disposable += username
.observeOn(Schedulers.io())
.map { UsernameUtil.generateLink(it) }
.flatMapSingle { generateQrCodeData(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
_state.value = _state.value.copy(
qrCodeData = qrData
)
}
}
override fun onCleared() {
disposable.clear()
}
fun onResume() {
_state.value = _state.value.copy(
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
}
private fun generateQrCodeData(url: String): Single<QrCodeData> {
return Single.fromCallable {
QrCodeData.forData(url, 64)
}
}
}

View file

@ -0,0 +1,194 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.util.UsernameUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* A screen that shows all the data around your username link and how to share it, including a QR code.
*/
@Composable
fun UsernameLinkShareScreen(
state: UsernameLinkSettingsState,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavController,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp)
) {
Column(
modifier = modifier
.padding(contentPadding)
.verticalScroll(rememberScrollState())
) {
QrCodeBadge(
data = state.qrCodeData,
colorScheme = state.qrCodeColorScheme,
username = state.username
)
ButtonBar(
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
)
CopyRow(
displayText = state.username,
copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast),
snackbarHostState = snackbarHostState,
scope = scope
)
CopyRow(
displayText = state.usernameLink,
copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast),
snackbarHostState = snackbarHostState,
scope = scope
)
Text(
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.Center
) {
Buttons.Small(onClick = { /*TODO*/ }) {
Text(
text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label)
)
}
}
}
}
@Composable
private fun ButtonBar(onColorClicked: () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 32.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth()
) {
Buttons.ActionButton(
onClick = {},
iconResId = R.drawable.symbol_share_android_24,
labelResId = R.string.UsernameLinkSettings_share_button_label
)
Buttons.ActionButton(
onClick = onColorClicked,
iconResId = R.drawable.symbol_color_24,
labelResId = R.string.UsernameLinkSettings_color_button_label
)
}
}
@Composable
private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background)
.clickable {
Util.copyToClipboard(context, displayText)
scope.launch {
snackbarHostState.showSnackbar(copyMessage)
}
}
.padding(horizontal = 26.dp, vertical = 16.dp)
) {
Image(
painter = painterResource(id = R.drawable.symbol_copy_android_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
Text(
text = displayText,
modifier = Modifier.padding(start = 26.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Preview(name = "Light Theme")
@Composable
private fun ScreenPreviewLightTheme() {
SignalTheme(isDarkMode = false) {
Surface {
UsernameLinkShareScreen(
state = previewState(),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope(),
navController = NavController(LocalContext.current)
)
}
}
}
@Preview(name = "Dark Theme")
@Composable
private fun ScreenPreviewDarkTheme() {
SignalTheme(isDarkMode = true) {
Surface {
UsernameLinkShareScreen(
state = previewState(),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope(),
navController = NavController(LocalContext.current)
)
}
}
}
private fun previewState(): UsernameLinkSettingsState {
val link = UsernameUtil.generateLink("maya.45")
return UsernameLinkSettingsState(
username = "maya.45",
usernameLink = link,
qrCodeData = QrCodeData.forData(link, 64),
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
)
}

View file

@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* A screen that allows you to scan a QR code to start a chat.
*/
@Composable
fun UsernameQrScanScreen(modifier: Modifier = Modifier) {
// TODO
Text(text = "QR Scanner Placeholder")
}

View file

@ -110,8 +110,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val disableTint by lazy { ContextCompat.getColor(requireContext(), R.color.core_grey_50) }
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
@ -130,7 +129,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val deleteIconDisabled by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_trash_24).apply {
colorFilter = PorterDuffColorFilter(disableTint, PorterDuff.Mode.SRC_IN)
colorFilter = PorterDuffColorFilter(alertDisabledTint, PorterDuff.Mode.SRC_IN)
}
}
@ -397,6 +396,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
ButtonStripPreference.Model(
state = state.buttonStripState,
enabled = !state.isDeprecatedOrUnregistered,
onMessageClick = {
val intent = ConversationIntents
.createBuilder(requireContext(), state.recipient.id, state.threadId)
@ -484,7 +484,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
summary = summary,
icon = DSLSettingsIcon.from(icon),
isEnabled = enabled,
isEnabled = enabled && !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
.setInitialValue(state.disappearingMessagesLifespan)
@ -510,6 +510,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
@ -554,6 +555,7 @@ 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 = {
startActivity(VerifyIdentityActivity.newIntent(requireActivity(), recipientState.identityRecord))
}
@ -629,6 +631,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_to_a_group),
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
viewModel.onAddToGroup()
}
@ -671,7 +674,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)))
}
if (groupState.canAddToGroup) {
if (groupState.canAddToGroup && !state.isDeprecatedOrUnregistered) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
@ -714,6 +717,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link),
summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off),
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString()))
}
@ -722,6 +726,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2()))
}
@ -731,6 +736,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId))
navController.safeNavigate(action)
@ -743,8 +749,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.conversation__menu_leave_group, alertTint),
title = DSLSettingsText.from(R.string.conversation__menu_leave_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint),
icon = DSLSettingsIcon.from(leaveIcon),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupState.groupId.requirePush(), null)
}
@ -773,12 +780,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
else -> R.string.ConversationSettingsFragment__block
}
val titleTint = if (isBlocked) null else alertTint
val titleTint = if (isBlocked) null else if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
clickPref(
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
icon = DSLSettingsIcon.from(blockUnblockIcon),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
if (state.recipient.isBlocked) {
BlockUnblockDialog.showUnblockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) {
@ -794,7 +802,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
state.withRecipientSettingsState { recipientState ->
clickPref(
title = DSLSettingsText.from(R.string.delete, if (recipientState.canDelete) alertTint else disableTint),
title = DSLSettingsText.from(R.string.delete, if (recipientState.canDelete) alertTint else alertDisabledTint),
icon = DSLSettingsIcon.from(if (recipientState.canDelete) deleteIcon else deleteIconDisabled),
isEnabled = recipientState.canDelete,
onClick = {

View file

@ -14,6 +14,7 @@ data class ConversationSettingsState(
val threadId: Long = -1,
val storyViewState: StoryViewState = StoryViewState.NONE,
val recipient: Recipient = Recipient.UNKNOWN,
val isDeprecatedOrUnregistered: Boolean = false,
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
val disappearingMessagesLifespan: Int = 0,
val canModifyBlockedState: Boolean = false,

View file

@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult
@ -29,6 +30,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Optional
@ -46,7 +48,8 @@ sealed class ConversationSettingsViewModel(
protected val store = Store(
ConversationSettingsState(
specificSettingsState = specificSettingsState
specificSettingsState = specificSettingsState,
isDeprecatedOrUnregistered = SignalStore.misc().isClientDeprecated || TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())
)
)
protected val internalEvents: Subject<ConversationSettingsEvent> = PublishSubject.create()

View file

@ -7,6 +7,7 @@ import androidx.appcompat.content.res.AppCompatResources
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@ -24,6 +25,7 @@ object ButtonStripPreference {
class Model(
val state: State,
val background: DSLSettingsIcon? = null,
val enabled: Boolean = true,
val onAddToStoryClick: () -> Unit = {},
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
@ -87,6 +89,11 @@ object ButtonStripPreference {
}
}
listOf(messageContainer, videoContainer, audioContainer, muteContainer, addToStoryContainer, searchContainer).forEach {
it.alpha = if (model.enabled) 1.0f else 0.5f
ViewUtil.setEnabledRecursive(it, model.enabled)
}
message.setOnClickListener { model.onMessageClick() }
videoCall.setOnClickListener { model.onVideoClick() }
audioCall.setOnClickListener { model.onAudioClick() }

View file

@ -22,6 +22,7 @@ object LargeIconClickPreference {
override val title: DSLSettingsText?,
override val icon: DSLSettingsIcon,
override val summary: DSLSettingsText? = null,
override val isEnabled: Boolean = true,
val onClick: () -> Unit
) : PreferenceModel<Model>()

View file

@ -57,7 +57,7 @@ final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.V
if (mode != selected) {
setSelectedOutput(mode);
onAudioOutputChangedListener.audioOutputChanged(selected);
onAudioOutputChangedListener.audioOutputChanged(new WebRtcAudioDevice(selected, null));
}
}

View file

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.webrtc
/**
* This is an interface for [WebRtcAudioPicker31] and [WebRtcAudioPickerLegacy] to reference methods in [WebRtcAudioOutputToggleButton] without actually depending on it.
*/
interface AudioStateUpdater {
fun updateAudioOutputState(audioOutput: WebRtcAudioOutput)
fun hidePicker()
}

View file

@ -1,5 +1,5 @@
package org.thoughtcrime.securesms.components.webrtc;
public interface OnAudioOutputChangedListener {
void audioOutputChanged(WebRtcAudioOutput audioOutput);
void audioOutputChanged(WebRtcAudioDevice device);
}

View file

@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.components.webrtc
import kotlin.math.min
/**
* This holds UI state for [WebRtcAudioOutputToggleButton]
*/
class ToggleButtonOutputState {
private val availableOutputs: LinkedHashSet<WebRtcAudioOutput> = linkedSetOf(WebRtcAudioOutput.SPEAKER)
private var selectedDevice = 0
set(value) {
if (value >= availableOutputs.size) {
throw IndexOutOfBoundsException("Index: $value, size: ${availableOutputs.size}")
}
field = value
}
var isEarpieceAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.HANDSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.HANDSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.HANDSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
var isBluetoothHeadsetAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.BLUETOOTH_HEADSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.BLUETOOTH_HEADSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.BLUETOOTH_HEADSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
var isWiredHeadsetAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.WIRED_HEADSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.WIRED_HEADSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.WIRED_HEADSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
@Deprecated("Used only for onSaveInstanceState.")
fun getBackingIndexForBackup(): Int {
return selectedDevice
}
@Deprecated("Used only for onRestoreInstanceState.")
fun setBackingIndexForRestore(index: Int) {
selectedDevice = 0
}
fun getCurrentOutput(): WebRtcAudioOutput {
return getOutputs()[selectedDevice]
}
fun setCurrentOutput(outputType: WebRtcAudioOutput): Boolean {
val newIndex = getOutputs().indexOf(outputType)
return if (newIndex < 0) {
false
} else {
selectedDevice = newIndex
true
}
}
fun getOutputs(): List<WebRtcAudioOutput> {
return availableOutputs.toList()
}
fun peekNext(): WebRtcAudioOutput {
val peekIndex = (selectedDevice + 1) % availableOutputs.size
return getOutputs()[peekIndex]
}
}

View file

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.webrtc
/**
* Holder class to smooth over the pre/post API 31 calls.
*
* @property webRtcAudioOutput audio device type, used by API 30 and below.
* @property deviceId specific ID for a specific device. Used only by API 31+.
*/
data class WebRtcAudioDevice(val webRtcAudioOutput: WebRtcAudioOutput, val deviceId: Int?)

View file

@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.DialogInterface
import android.os.Bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -59,21 +58,26 @@ class WebRtcAudioOutputBottomSheet : ComposeBottomSheetDialogFragment(), DialogI
dismiss()
}
fun show(fm: FragmentManager, tag: String?, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit) {
fun show(fm: FragmentManager, tag: String?, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit, onDismiss: (DialogInterface) -> Unit) {
super.showNow(fm, tag)
viewModel.audioRoutes = audioRoutes
viewModel.defaultDeviceId = selectedDeviceId
viewModel.onClick = onClick
viewModel.onDismiss = onDismiss
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
viewModel.onDismiss(dialog)
}
companion object {
const val TAG = "WebRtcAudioOutputBottomSheet"
@JvmStatic
fun show(fragmentManager: FragmentManager, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit): WebRtcAudioOutputBottomSheet {
fun show(fragmentManager: FragmentManager, audioRoutes: List<AudioOutputOption>, selectedDeviceId: Int, onClick: (AudioOutputOption) -> Unit, onDismiss: (DialogInterface) -> Unit): WebRtcAudioOutputBottomSheet {
val bottomSheet = WebRtcAudioOutputBottomSheet()
val args = Bundle()
bottomSheet.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG, audioRoutes, selectedDeviceId, onClick)
bottomSheet.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG, audioRoutes, selectedDeviceId, onClick, onDismiss)
return bottomSheet
}
}
@ -134,6 +138,7 @@ class AudioOutputViewModel : ViewModel() {
var audioRoutes: List<AudioOutputOption> = emptyList()
var defaultDeviceId: Int = -1
var onClick: (AudioOutputOption) -> Unit = {}
var onDismiss: (DialogInterface) -> Unit = {}
}
private fun getDrawableResourceForDeviceType(deviceType: SignalAudioManager.AudioDevice): Int {

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.content.ContextWrapper
import android.content.DialogInterface
import android.media.AudioDeviceInfo
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
@ -13,43 +12,46 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatImageView
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.webrtc.audio.AudioDeviceMapping
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import kotlin.math.min
/**
* A UI button that triggers a picker dialog/bottom sheet allowing the user to select the audio output for the ongoing call.
*/
class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr) {
class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr), AudioStateUpdater {
private val TAG = Log.tag(WebRtcAudioOutputToggleButton::class.java)
private var outputState: OutputState = OutputState()
private var outputState: ToggleButtonOutputState = ToggleButtonOutputState()
private var audioOutputChangedListenerLegacy: OnAudioOutputChangedListener? = null
private var audioOutputChangedListener31: OnAudioOutputChangedListener31? = null
private var audioOutputChangedListener: OnAudioOutputChangedListener = OnAudioOutputChangedListener { Log.e(TAG, "Attempted to call audioOutputChangedListenerLegacy without initializing!") }
private var picker: DialogInterface? = null
private val clickListenerLegacy: OnClickListener = OnClickListener {
if (picker != null) {
Log.d(TAG, "Tried to launch new audio device picker but one is already present.")
return@OnClickListener
}
val outputs = outputState.getOutputs()
if (outputs.size >= SHOW_PICKER_THRESHOLD || !outputState.isEarpieceAvailable) {
showPickerLegacy(outputs)
picker = WebRtcAudioPickerLegacy(audioOutputChangedListener, outputState, this).showPicker(context, outputs) { picker = null }
} else {
setAudioOutput(outputState.peekNext(), true)
val audioOutput = outputState.peekNext()
audioOutputChangedListener.audioOutputChanged(WebRtcAudioDevice(audioOutput, null))
updateAudioOutputState(audioOutput)
}
}
@RequiresApi(31)
private val clickListener31 = OnClickListener {
if (picker != null) {
Log.d(TAG, "Tried to launch new audio device picker but one is already present.")
return@OnClickListener
}
val fragmentActivity = context.fragmentActivity()
if (fragmentActivity != null) {
showPicker31(fragmentActivity.supportFragmentManager)
picker = WebRtcAudioPicker31(audioOutputChangedListener, outputState, this).showPicker(fragmentActivity, SHOW_PICKER_THRESHOLD) { picker = null }
} else {
Log.e(TAG, "WebRtcAudioOutputToggleButton instantiated from a context that does not inherit from FragmentActivity.")
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_fragment_activity_error, Toast.LENGTH_LONG).show()
@ -83,11 +85,22 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
}
val currentOutput = outputState.getCurrentOutput()
val extra = when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_handset_selected)
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_selected)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
val numberOfOutputs = outputState.getOutputs().size
val extra = if (numberOfOutputs < SHOW_PICKER_THRESHOLD) {
when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_on)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected) // should never be seen in practice.
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected) // should never be seen in practice.
}
} else {
when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_handset_selected)
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_selected)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
}
}
val label = context.getString(currentOutput.labelRes)
@ -106,89 +119,19 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
outputState.isEarpieceAvailable = isEarpieceAvailable
outputState.isBluetoothHeadsetAvailable = isBluetoothHeadsetAvailable
outputState.isWiredHeadsetAvailable = isHeadsetAvailable
refreshDrawableState()
}
fun setAudioOutput(audioOutput: WebRtcAudioOutput, notifyListener: Boolean) {
override fun updateAudioOutputState(audioOutput: WebRtcAudioOutput) {
val oldOutput = outputState.getCurrentOutput()
if (oldOutput != audioOutput) {
outputState.setCurrentOutput(audioOutput)
refreshDrawableState()
if (notifyListener) {
audioOutputChangedListenerLegacy?.audioOutputChanged(audioOutput)
}
}
}
fun setOnAudioOutputChangedListenerLegacy(listener: OnAudioOutputChangedListener?) {
audioOutputChangedListenerLegacy = listener
}
@RequiresApi(31)
fun setOnAudioOutputChangedListener31(listener: OnAudioOutputChangedListener31?) {
audioOutputChangedListener31 = listener
}
private fun showPickerLegacy(availableModes: List<WebRtcAudioOutput?>) {
val rv = RecyclerView(context)
val adapter = AudioOutputAdapter(
{ audioOutput: WebRtcAudioOutput ->
setAudioOutput(audioOutput, true)
hidePicker()
},
availableModes
)
adapter.setSelectedOutput(outputState.getCurrentOutput())
rv.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
rv.adapter = adapter
picker = MaterialAlertDialogBuilder(context)
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
.setView(rv)
.setCancelable(true)
.show()
}
@RequiresApi(31)
private fun showPicker31(fragmentManager: FragmentManager) {
val am = ApplicationDependencies.getAndroidCallAudioManager()
if (am.availableCommunicationDevices.isEmpty()) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
return
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }
picker = WebRtcAudioOutputBottomSheet.show(fragmentManager, devices, am.communicationDevice?.id ?: -1) {
audioOutputChangedListener31?.audioOutputChanged(it.deviceId)
when (it.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> {
outputState.isWiredHeadsetAvailable = true
setAudioOutput(WebRtcAudioOutput.WIRED_HEADSET, true)
}
SignalAudioManager.AudioDevice.EARPIECE -> {
outputState.isEarpieceAvailable = true
setAudioOutput(WebRtcAudioOutput.HANDSET, true)
}
SignalAudioManager.AudioDevice.BLUETOOTH -> {
outputState.isBluetoothHeadsetAvailable = true
setAudioOutput(WebRtcAudioOutput.BLUETOOTH_HEADSET, true)
}
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> setAudioOutput(WebRtcAudioOutput.SPEAKER, true)
}
}
}
@RequiresApi(23)
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
return when (this.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
else -> this.productName
}
fun setOnAudioOutputChangedListener(listener: OnAudioOutputChangedListener) {
audioOutputChangedListener = listener
}
override fun onSaveInstanceState(): Parcelable {
@ -213,7 +156,7 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
}
}
private fun hidePicker() {
override fun hidePicker() {
try {
picker?.dismiss()
} catch (e: IllegalStateException) {
@ -223,84 +166,9 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
picker = null
}
inner class OutputState {
private val availableOutputs: LinkedHashSet<WebRtcAudioOutput> = linkedSetOf(WebRtcAudioOutput.SPEAKER)
private var selectedDevice = 0
set(value) {
if (value >= availableOutputs.size) {
throw IndexOutOfBoundsException("Index: $value, size: ${availableOutputs.size}")
}
field = value
}
@Deprecated("Used only for onSaveInstanceState.")
fun getBackingIndexForBackup(): Int {
return selectedDevice
}
@Deprecated("Used only for onRestoreInstanceState.")
fun setBackingIndexForRestore(index: Int) {
selectedDevice = 0
}
fun getCurrentOutput(): WebRtcAudioOutput {
return getOutputs()[selectedDevice]
}
fun setCurrentOutput(outputType: WebRtcAudioOutput): Boolean {
val newIndex = getOutputs().indexOf(outputType)
return if (newIndex < 0) {
false
} else {
selectedDevice = newIndex
true
}
}
fun getOutputs(): List<WebRtcAudioOutput> {
return availableOutputs.toList()
}
fun peekNext(): WebRtcAudioOutput {
val peekIndex = (selectedDevice + 1) % availableOutputs.size
return getOutputs()[peekIndex]
}
var isEarpieceAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.HANDSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.HANDSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.HANDSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
var isBluetoothHeadsetAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.BLUETOOTH_HEADSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.BLUETOOTH_HEADSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.BLUETOOTH_HEADSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
var isWiredHeadsetAvailable: Boolean
get() = availableOutputs.contains(WebRtcAudioOutput.WIRED_HEADSET)
set(value) {
if (value) {
availableOutputs.add(WebRtcAudioOutput.WIRED_HEADSET)
} else {
availableOutputs.remove(WebRtcAudioOutput.WIRED_HEADSET)
selectedDevice = min(selectedDevice, availableOutputs.size - 1)
}
}
}
companion object {
private const val SHOW_PICKER_THRESHOLD = 3
const val SHOW_PICKER_THRESHOLD = 3
private const val STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index"
private const val STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled"
private const val STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled"

View file

@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.content.DialogInterface
import android.media.AudioDeviceInfo
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.fragment.app.FragmentActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.webrtc.audio.AudioDeviceMapping
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
/**
* This launches the bottom sheet on Android 12+ devices for selecting which audio device to use during a call.
* In cases where there are fewer than the provided threshold number of devices, it will cycle through them without presenting a bottom sheet.
*/
@RequiresApi(31)
class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputChangedListener, private val outputState: ToggleButtonOutputState, private val stateUpdater: AudioStateUpdater) {
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
val am = ApplicationDependencies.getAndroidCallAudioManager()
if (am.availableCommunicationDevices.isEmpty()) {
Toast.makeText(fragmentActivity, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
return null
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(fragmentActivity).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }
val currentDeviceId = am.communicationDevice?.id ?: -1
if (devices.size < threshold) {
if (devices.isEmpty()) return null
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index == -1) return null
onAudioDeviceSelected(devices[(index + 1) % devices.size])
return null
} else {
return WebRtcAudioOutputBottomSheet.show(fragmentActivity.supportFragmentManager, devices, currentDeviceId, onAudioDeviceSelected, onDismiss)
}
}
@RequiresApi(31)
val onAudioDeviceSelected: (AudioOutputOption) -> Unit = {
audioOutputChangedListener.audioOutputChanged(WebRtcAudioDevice(it.toWebRtcAudioOutput(), it.deviceId))
when (it.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> {
outputState.isWiredHeadsetAvailable = true
stateUpdater.updateAudioOutputState(WebRtcAudioOutput.WIRED_HEADSET)
}
SignalAudioManager.AudioDevice.EARPIECE -> {
outputState.isEarpieceAvailable = true
stateUpdater.updateAudioOutputState(WebRtcAudioOutput.HANDSET)
}
SignalAudioManager.AudioDevice.BLUETOOTH -> {
outputState.isBluetoothHeadsetAvailable = true
stateUpdater.updateAudioOutputState(WebRtcAudioOutput.BLUETOOTH_HEADSET)
}
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> stateUpdater.updateAudioOutputState(WebRtcAudioOutput.SPEAKER)
}
}
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
return when (this.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
else -> this.productName
}
}
private fun AudioOutputOption.toWebRtcAudioOutput(): WebRtcAudioOutput {
return when (this.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> WebRtcAudioOutput.WIRED_HEADSET
SignalAudioManager.AudioDevice.EARPIECE -> WebRtcAudioOutput.HANDSET
SignalAudioManager.AudioDevice.BLUETOOTH -> WebRtcAudioOutput.BLUETOOTH_HEADSET
SignalAudioManager.AudioDevice.SPEAKER_PHONE, SignalAudioManager.AudioDevice.NONE -> WebRtcAudioOutput.SPEAKER
}
}
}

View file

@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import android.content.DialogInterface
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
/**
* This launches the bottom sheet on Android 11 and below devices for selecting which audio device to use during a call.
* In cases where there are only [SHOW_PICKER_THRESHOLD] devices, it will cycle through them without presenting a bottom sheet.
*/
class WebRtcAudioPickerLegacy(private val audioOutputChangedListener: OnAudioOutputChangedListener, private val outputState: ToggleButtonOutputState, private val stateUpdater: AudioStateUpdater) {
fun showPicker(context: Context, availableModes: List<WebRtcAudioOutput?>, dismissListener: DialogInterface.OnDismissListener): DialogInterface? {
val rv = RecyclerView(context)
val adapter = AudioOutputAdapter(
fun(audioDevice: WebRtcAudioDevice) {
audioOutputChangedListener.audioOutputChanged(audioDevice)
stateUpdater.updateAudioOutputState(audioDevice.webRtcAudioOutput)
stateUpdater.hidePicker()
},
availableModes
)
adapter.setSelectedOutput(outputState.getCurrentOutput())
rv.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
rv.adapter = adapter
return MaterialAlertDialogBuilder(context)
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
.setView(rv)
.setCancelable(true)
.setOnDismissListener(dismissListener)
.show()
}
}

View file

@ -43,6 +43,7 @@ import com.google.common.collect.Sets;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.SetUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
@ -71,6 +72,8 @@ import java.util.Set;
public class WebRtcCallView extends ConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
@ -241,16 +244,21 @@ public class WebRtcCallView extends ConstraintLayout {
adjustableMarginsSet.add(videoToggle);
adjustableMarginsSet.add(audioToggle);
if (Build.VERSION.SDK_INT >= 31) {
audioToggle.setOnAudioOutputChangedListener31(deviceId -> {
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged31(deviceId));
audioToggle.setOnAudioOutputChangedListener(webRtcAudioDevice -> {
runIfNonNull(controlsListener, listener ->
{
if (Build.VERSION.SDK_INT >= 31) {
final Integer deviceId = webRtcAudioDevice.getDeviceId();
if (deviceId != null) {
listener.onAudioOutputChanged31(deviceId);
} else {
Log.e(TAG, "Attempted to change audio output to null device ID.");
}
} else {
listener.onAudioOutputChanged(webRtcAudioDevice.getWebRtcAudioOutput());
}
});
} else {
audioToggle.setOnAudioOutputChangedListenerLegacy(outputMode -> {
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
});
}
});
videoToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn));
@ -409,6 +417,10 @@ public class WebRtcCallView extends ConstraintLayout {
this.controlsListener = controlsListener;
}
public void maybeDismissAudioPicker() {
audioToggle.hidePicker();
}
public void setMicEnabled(boolean isMicEnabled) {
micToggle.setChecked(isMicEnabled, false);
}
@ -671,7 +683,7 @@ public class WebRtcCallView extends ConstraintLayout {
webRtcControls.isBluetoothHeadsetAvailableForAudioToggle(),
webRtcControls.isWiredHeadsetAvailableForAudioToggle());
audioToggle.setAudioOutput(webRtcControls.getAudioOutput(), false);
audioToggle.updateAudioOutputState(webRtcControls.getAudioOutput());
}
if (webRtcControls.displayCameraToggle()) {
@ -1082,7 +1094,7 @@ public class WebRtcCallView extends ConstraintLayout {
void hideSystemUI();
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
@RequiresApi(31)
void onAudioOutputChanged31(@NonNull int audioOutputAddress);
void onAudioOutputChanged31(@NonNull Integer audioOutputAddress);
void onVideoChanged(boolean isVideoEnabled);
void onMicChanged(boolean isMicEnabled);
void onCameraDirectionChanged();

View file

@ -181,7 +181,7 @@ public final class WebRtcControls {
}
boolean isWiredHeadsetAvailableForAudioToggle() {
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
return availableDevices.contains(SignalAudioManager.AudioDevice.WIRED_HEADSET);
}
boolean isFadeOutEnabled() {

View file

@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
@ -78,7 +79,8 @@ import java.util.Set;
*/
public class ConversationAdapter
extends ListAdapter<ConversationMessage, RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>,
ConversationAdapterBridge
{
private static final String TAG = Log.tag(ConversationAdapter.class);
@ -105,8 +107,6 @@ public class ConversationAdapter
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final Locale locale;
private final Recipient recipient;
private final Set<MultiselectPart> selected;
private final Calendar calendar;
@ -129,7 +129,7 @@ public class ConversationAdapter
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@NonNull Recipient recipient,
boolean hasWallpaper,
@NonNull Colorizer colorizer)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@ -150,10 +150,9 @@ public class ConversationAdapter
this.glideRequests = glideRequests;
this.locale = locale;
this.clickListener = clickListener;
this.recipient = recipient;
this.selected = new HashSet<>();
this.calendar = Calendar.getInstance();
this.hasWallpaper = recipient.hasWallpaper();
this.hasWallpaper = hasWallpaper;
this.isMessageRequestAccepted = true;
this.colorizer = colorizer;
}
@ -292,7 +291,7 @@ public class ConversationAdapter
glideRequests,
locale,
selected,
recipient,
conversationMessage.getThreadRecipient(),
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper && displayMode.displayWallpaper(),
@ -383,6 +382,10 @@ public class ConversationAdapter
}
}
public @Nullable ConversationMessage getConversationMessage(int position) {
return getItem(position);
}
public @Nullable ConversationMessage getItem(int position) {
position = isTypingViewEnabled() ? position - 1 : position;
@ -440,7 +443,8 @@ public class ConversationAdapter
}
public boolean isForRecipientId(@NonNull RecipientId recipientId) {
return recipient.getId().equals(recipientId);
// TODO [alex] -- This should be fine, since we now have a 1:1 relationship between fragment and recipient.
return true;
}
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, long unreadCount) {
@ -455,7 +459,7 @@ public class ConversationAdapter
}
}
boolean hasNoConversationMessages() {
public boolean hasNoConversationMessages() {
return super.getItemCount() == 0;
}
@ -562,7 +566,7 @@ public class ConversationAdapter
* Lets the adapter know that the wallpaper state has changed.
* @return True if the internal wallpaper state changed, otherwise false.
*/
boolean onHasWallpaperChanged(boolean hasWallpaper) {
public boolean onHasWallpaperChanged(boolean hasWallpaper) {
if (this.hasWallpaper != hasWallpaper) {
Log.d(TAG, "Resetting adapter due to wallpaper change.");
this.hasWallpaper = hasWallpaper;
@ -827,37 +831,6 @@ public class ConversationAdapter
}
}
public static class PulseRequest {
private final int position;
private final boolean isOutgoing;
PulseRequest(int position, boolean isOutgoing) {
this.position = position;
this.isOutgoing = isOutgoing;
}
public int getPosition() {
return position;
}
public boolean isOutgoing() {
return isOutgoing;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final PulseRequest that = (PulseRequest) o;
return position == that.position;
}
@Override
public int hashCode() {
return Objects.hash(position);
}
}
public interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(MultiselectPart item);
void onItemLongClick(View itemView, MultiselectPart item);

View file

@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
/**
* Temporary shared interface between the two conversation adapters strictly for use in
* shared decorators and other utils.
*/
interface ConversationAdapterBridge {
fun hasNoConversationMessages(): Boolean
fun getConversationMessage(position: Int): ConversationMessage?
fun consumePulseRequest(): PulseRequest?
val selectedItems: Set<MultiselectPart>
data class PulseRequest(val position: Int, val isOutgoing: Boolean)
}

View file

@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.conversation
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents metadata about a conversation.
*/
data class ConversationData(
val threadRecipient: Recipient,
val threadId: Long,
val lastSeen: Long,
val lastSeenPosition: Int,

View file

@ -13,6 +13,12 @@ import org.signal.paging.PagedDataSource;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.conversation.ConversationData.MessageRequestData;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper;
import org.thoughtcrime.securesms.conversation.v2.data.CallHelper;
import org.thoughtcrime.securesms.conversation.v2.data.MentionHelper;
import org.thoughtcrime.securesms.conversation.v2.data.PaymentHelper;
import org.thoughtcrime.securesms.conversation.v2.data.QuotedHelper;
import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper;
import org.thoughtcrime.securesms.database.CallTable;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -24,24 +30,15 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.payments.Payment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Core data source for loading an individual conversation.
@ -58,12 +55,22 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
/** Used once for the initial fetch, then cleared. */
private int baseSize;
public ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate, int baseSize) {
private final Recipient threadRecipient;
public ConversationDataSource(
@NonNull Context context,
long threadId,
@NonNull MessageRequestData messageRequestData,
boolean showUniversalExpireTimerUpdate,
int baseSize,
@NonNull Recipient threadRecipient
) {
this.context = context;
this.threadId = threadId;
this.messageRequestData = messageRequestData;
this.showUniversalExpireTimerUpdate = showUniversalExpireTimerUpdate;
this.baseSize = baseSize;
this.threadRecipient = threadRecipient;
}
@Override
@ -171,7 +178,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
stopwatch.split("recipient-resolves");
List<ConversationMessage> messages = Stream.of(records)
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, m.getDisplayBody(context), mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId())))
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, m.getDisplayBody(context), mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId()), threadRecipient))
.toList();
stopwatch.split("conversion");
@ -233,7 +240,8 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
record,
record.getDisplayBody(ApplicationDependencies.getApplication()),
mentions,
isQuoted);
isQuoted,
threadRecipient);
} else {
return null;
}
@ -246,191 +254,4 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
public @NonNull MessageId getKey(@NonNull ConversationMessage conversationMessage) {
return new MessageId(conversationMessage.getMessageRecord().getId());
}
private static class MentionHelper {
private Collection<Long> messageIds = new LinkedList<>();
private Map<Long, List<Mention>> messageIdToMentions = new HashMap<>();
void add(MessageRecord record) {
if (record.isMms()) {
messageIds.add(record.getId());
}
}
void fetchMentions(Context context) {
messageIdToMentions = SignalDatabase.mentions().getMentionsForMessages(messageIds);
}
@Nullable List<Mention> getMentions(long id) {
return messageIdToMentions.get(id);
}
}
private static class QuotedHelper {
private Collection<MessageRecord> records = new LinkedList<>();
private Set<Long> hasBeenQuotedIds = new HashSet<>();
void add(MessageRecord record) {
records.add(record);
}
void fetchQuotedState() {
hasBeenQuotedIds = SignalDatabase.messages().isQuoted(records);
}
boolean isQuoted(long id) {
return hasBeenQuotedIds.contains(id);
}
}
public static class AttachmentHelper {
private Collection<Long> messageIds = new LinkedList<>();
private Map<Long, List<DatabaseAttachment>> messageIdToAttachments = new HashMap<>();
public void add(MessageRecord record) {
if (record.isMms()) {
messageIds.add(record.getId());
}
}
public void addAll(List<MessageRecord> records) {
for (MessageRecord record : records) {
add(record);
}
}
public void fetchAttachments() {
messageIdToAttachments = SignalDatabase.attachments().getAttachmentsForMessages(messageIds);
}
public @NonNull List<MessageRecord> buildUpdatedModels(@NonNull Context context, @NonNull List<MessageRecord> records) {
return records.stream()
.map(record -> {
if (record instanceof MediaMmsMessageRecord) {
List<DatabaseAttachment> attachments = messageIdToAttachments.get(record.getId());
if (Util.hasItems(attachments)) {
return ((MediaMmsMessageRecord) record).withAttachments(context, attachments);
}
}
return record;
})
.collect(Collectors.toList());
}
}
public static class ReactionHelper {
private Collection<MessageId> messageIds = new LinkedList<>();
private Map<MessageId, List<ReactionRecord>> messageIdToReactions = new HashMap<>();
public void add(MessageRecord record) {
messageIds.add(new MessageId(record.getId()));
}
public void addAll(List<MessageRecord> records) {
for (MessageRecord record : records) {
add(record);
}
}
public void fetchReactions() {
messageIdToReactions = SignalDatabase.reactions().getReactionsForMessages(messageIds);
}
public @NonNull List<MessageRecord> buildUpdatedModels(@NonNull List<MessageRecord> records) {
return records.stream()
.map(record -> {
MessageId messageId = new MessageId(record.getId());
List<ReactionRecord> reactions = messageIdToReactions.get(messageId);
return recordWithReactions(record, reactions);
})
.collect(Collectors.toList());
}
private static MessageRecord recordWithReactions(@NonNull MessageRecord record, List<ReactionRecord> reactions) {
if (Util.hasItems(reactions)) {
if (record instanceof MediaMmsMessageRecord) {
return ((MediaMmsMessageRecord) record).withReactions(reactions);
} else {
throw new IllegalStateException("We have reactions for an unsupported record type: " + record.getClass().getName());
}
} else {
return record;
}
}
}
private static class PaymentHelper {
private final Map<UUID, Long> paymentMessages = new HashMap<>();
private final Map<Long, Payment> messageIdToPayment = new HashMap<>();
public void add(MessageRecord messageRecord) {
if (messageRecord.isMms() && messageRecord.isPaymentNotification()) {
UUID paymentUuid = UuidUtil.parseOrNull(messageRecord.getBody());
if (paymentUuid != null) {
paymentMessages.put(paymentUuid, messageRecord.getId());
}
}
}
public void fetchPayments() {
List<Payment> payments = SignalDatabase.payments().getPayments(paymentMessages.keySet());
for (Payment payment : payments) {
if (payment != null) {
messageIdToPayment.put(paymentMessages.get(payment.getUuid()), payment);
}
}
}
@NonNull List<MessageRecord> buildUpdatedModels(@NonNull List<MessageRecord> records) {
return records.stream()
.map(record -> {
if (record instanceof MediaMmsMessageRecord) {
Payment payment = messageIdToPayment.get(record.getId());
if (payment != null) {
return ((MediaMmsMessageRecord) record).withPayment(payment);
}
}
return record;
})
.collect(Collectors.toList());
}
}
private static class CallHelper {
private final Collection<Long> messageIds = new LinkedList<>();
private Map<Long, CallTable.Call> messageIdToCall = Collections.emptyMap();
public void add(MessageRecord messageRecord) {
if (messageRecord.isCallLog() && !messageRecord.isGroupCall()) {
messageIds.add(messageRecord.getId());
}
}
public void fetchCalls() {
if (!messageIds.isEmpty()) {
messageIdToCall = SignalDatabase.calls().getCalls(messageIds);
}
}
@NonNull List<MessageRecord> buildUpdatedModels(@NonNull List<MessageRecord> records) {
return records.stream()
.map(record -> {
if (record.isCallLog() && record instanceof MediaMmsMessageRecord) {
CallTable.Call call = messageIdToCall.get(record.getId());
if (call != null) {
return ((MediaMmsMessageRecord) record).withCall(call);
}
}
return record;
})
.collect(Collectors.toList());
}
}
}

View file

@ -17,12 +17,8 @@
package org.thoughtcrime.securesms.conversation;
import android.Manifest;
import android.animation.Animator;
import android.animation.LayoutTransition;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.app.ActivityOptions;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
@ -46,7 +42,6 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ViewSwitcher;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -77,6 +72,7 @@ import org.jetbrains.annotations.NotNull;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
@ -109,6 +105,7 @@ import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.conversation.v2.AddToContactsContract;
import org.thoughtcrime.securesms.conversation.v2.BubbleLayoutTransitionListener;
import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -131,7 +128,6 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment;
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult;
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
@ -174,9 +170,8 @@ import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@ -245,10 +240,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private ConversationGroupViewModel groupViewModel;
private SnapToTopDataObserver snapToTopDataObserver;
private MarkReadHelper markReadHelper;
private Animation scrollButtonInAnimation;
private Animation mentionButtonInAnimation;
private Animation scrollButtonOutAnimation;
private Animation mentionButtonOutAnimation;
private OnScrollListener conversationScrollListener;
private int lastSeenScrollOffset;
private Stopwatch startupStopwatch;
@ -391,19 +382,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}));
conversationViewModel.getShowMentionsButton().observe(getViewLifecycleOwner(), shouldShow -> {
if (shouldShow) {
ViewUtil.animateIn(scrollToMentionButton, mentionButtonInAnimation);
} else {
ViewUtil.animateOut(scrollToMentionButton, mentionButtonOutAnimation, View.INVISIBLE);
}
scrollToMentionButton.setShown(shouldShow);
});
conversationViewModel.getShowScrollToBottom().observe(getViewLifecycleOwner(), shouldShow -> {
if (shouldShow) {
ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation);
} else {
ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE);
}
scrollToBottomButton.setShown(shouldShow);
});
scrollToBottomButton.setOnClickListener(v -> scrollToBottom());
@ -432,7 +415,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
conversationViewModel.getActiveNotificationProfile().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus);
initializeScrollButtonAnimations();
initializeResources();
initializeMessageRequestViewModel();
initializeListAdapter();
@ -711,7 +693,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(requireContext(), this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), colorizer);
ConversationAdapter adapter = new ConversationAdapter(requireContext(), this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get().hasWallpaper(), colorizer);
adapter.setPagingController(conversationViewModel.getPagingController());
list.setAdapter(adapter);
setInlineDateDecoration(adapter);
@ -1015,7 +997,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
try (InputStream stream = PartAuthority.getAttachmentStream(requireContext(), textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.getMessageRecord(), body)
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.getMessageRecord(), body, message.getThreadRecipient())
.getDisplayBody(requireContext());
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
@ -1350,20 +1332,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
private void initializeScrollButtonAnimations() {
scrollButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in);
scrollButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out);
mentionButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in);
mentionButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out);
scrollButtonInAnimation.setDuration(100);
scrollButtonOutAnimation.setDuration(50);
mentionButtonInAnimation.setDuration(100);
mentionButtonOutAnimation.setDuration(50);
}
private void scrollToNextMention() {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return SignalDatabase.messages().getOldestUnreadMentionDetails(threadId);
@ -1919,16 +1887,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onChatSessionRefreshLearnMoreClicked() {
new AlertDialog.Builder(requireContext())
.setView(R.layout.decryption_failed_dialog)
.setPositiveButton(android.R.string.ok, (d, w) -> {
d.dismiss();
})
.setNeutralButton(R.string.ConversationFragment_contact_us, (d, w) -> {
startActivity(AppSettingsActivity.help(requireContext(), 0));
d.dismiss();
})
.show();
ConversationDialogs.INSTANCE.displayChatSessionRefreshLearnMoreDialog(requireContext());
}
@Override
@ -1940,34 +1899,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onSafetyNumberLearnMoreClicked(@NonNull Recipient recipient) {
if (recipient.isGroup()) {
throw new AssertionError("Must be individual");
}
AlertDialog dialog = new AlertDialog.Builder(requireContext())
.setView(R.layout.safety_number_changed_learn_more_dialog)
.setPositiveButton(R.string.ConversationFragment_verify, (d, w) -> {
SimpleTask.run(getLifecycle(), () -> {
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId());
}, identityRecord -> {
if (identityRecord.isPresent()) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord.get()));
}});
d.dismiss();
})
.setNegativeButton(R.string.ConversationFragment_not_now, (d, w) -> {
d.dismiss();
})
.create();
dialog.setOnShowListener(d -> {
TextView title = Objects.requireNonNull(dialog.findViewById(R.id.safety_number_learn_more_title));
TextView body = Objects.requireNonNull(dialog.findViewById(R.id.safety_number_learn_more_body));
title.setText(getString(R.string.ConversationFragment_your_safety_number_with_s_changed, recipient.getDisplayName(requireContext())));
body.setText(getString(R.string.ConversationFragment_your_safety_number_with_s_changed_likey_because_they_reinstalled_signal, recipient.getDisplayName(requireContext())));
});
dialog.show();
ConversationDialogs.INSTANCE.displaySafetyNumberLearnMoreDialog(ConversationFragment.this, recipient);
}
@Override
public void onJoinGroupCallClicked() {
@ -1996,15 +1928,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord) {
if (messageRecord instanceof InMemoryMessageRecord.NoGroupsInCommon) {
boolean isGroup = ((InMemoryMessageRecord.NoGroupsInCommon) messageRecord).isGroup();
new MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_Signal_MaterialAlertDialog)
.setMessage(isGroup ? R.string.GroupsInCommonMessageRequest__none_of_your_contacts_or_people_you_chat_with_are_in_this_group
: R.string.GroupsInCommonMessageRequest__you_have_no_groups_in_common_with_this_person)
.setNeutralButton(R.string.GroupsInCommonMessageRequest__about_message_requests, (d, w) -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.GroupsInCommonMessageRequest__support_article)))
.setPositiveButton(R.string.GroupsInCommonMessageRequest__okay, null)
.show();
}
ConversationDialogs.INSTANCE.displayInMemoryMessageDialog(requireContext(), messageRecord);
}
@Override
@ -2069,7 +1993,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
sharedElement.setTransitionName(MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME);
requireActivity().setExitSharedElementCallback(new MaterialContainerTransformSharedElementCallback());
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), sharedElement, MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME);
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
final Intent mediaPreviewIntent = MediaIntentFactory.create(requireActivity(), args);
if (listener.isInBubble()) {
mediaPreviewIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
requireActivity().startActivity(mediaPreviewIntent, options.toBundle());
}
@Override
@ -2093,7 +2026,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
@Override
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) {
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull ConversationMessage conversationMessage) {
}
}

View file

@ -2258,7 +2258,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private class ScheduledIndicatorClickListener implements View.OnClickListener {
public void onClick(final View view) {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onScheduledIndicatorClicked(view, (messageRecord));
eventListener.onScheduledIndicatorClicked(view, (conversationMessage));
} else {
passthroughClickListener.onClick(view);
}

View file

@ -17,11 +17,13 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* A view level model used to pass arbitrary message related information needed
@ -33,18 +35,21 @@ public class ConversationMessage {
@Nullable private final SpannableString body;
@NonNull private final MultiselectCollection multiselectCollection;
@NonNull private final MessageStyler.Result styleResult;
@NonNull private final Recipient threadRecipient;
private final boolean hasBeenQuoted;
private ConversationMessage(@NonNull MessageRecord messageRecord,
@Nullable CharSequence body,
@Nullable List<Mention> mentions,
boolean hasBeenQuoted,
@Nullable MessageStyler.Result styleResult)
@Nullable MessageStyler.Result styleResult,
@NonNull Recipient threadRecipient)
{
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
this.mentions = mentions != null ? mentions : Collections.emptyList();
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
this.mentions = mentions != null ? mentions : Collections.emptyList();
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
this.threadRecipient = threadRecipient;
if (body != null) {
this.body = SpannableString.valueOf(body);
@ -119,6 +124,10 @@ public class ConversationMessage {
return MessageRecordUtil.isScheduled(messageRecord);
}
@NonNull public Recipient getThreadRecipient() {
return threadRecipient;
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/
@ -135,7 +144,8 @@ public class ConversationMessage {
@NonNull MessageRecord messageRecord,
@NonNull CharSequence body,
@Nullable List<Mention> mentions,
boolean hasBeenQuoted)
boolean hasBeenQuoted,
@NonNull Recipient threadRecipient)
{
SpannableString styledAndMentionBody = null;
MessageStyler.Result styleResult = MessageStyler.Result.none();
@ -157,7 +167,8 @@ public class ConversationMessage {
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
mentionsUpdate != null ? mentionsUpdate.getMentions() : null,
hasBeenQuoted,
styleResult);
styleResult,
threadRecipient);
}
/**
@ -166,8 +177,8 @@ public class ConversationMessage {
* database operations to query for mentions and then to resolve mentions to display names.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord) {
return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context));
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull Recipient threadRecipient) {
return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context), threadRecipient);
}
/**
@ -176,10 +187,10 @@ public class ConversationMessage {
* database operations to query for mentions and then to resolve mentions to display names.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, boolean hasBeenQuoted) {
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, boolean hasBeenQuoted, @NonNull Recipient threadRecipient) {
List<Mention> mentions = messageRecord.isMms() ? SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId())
: null;
return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context), mentions, hasBeenQuoted);
return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context), mentions, hasBeenQuoted, threadRecipient);
}
/**
@ -188,11 +199,11 @@ public class ConversationMessage {
* database operations to query for mentions and then to resolve mentions to display names.
*/
@WorkerThread
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) {
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body, @NonNull Recipient threadRecipient) {
boolean hasBeenQuoted = SignalDatabase.messages().isQuoted(messageRecord);
List<Mention> mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId());
return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted);
return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted, threadRecipient);
}
}
}

View file

@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import android.view.Menu
@ -8,6 +13,7 @@ import androidx.core.view.MenuProvider
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -18,6 +24,8 @@ import org.thoughtcrime.securesms.recipients.Recipient
*/
internal object ConversationOptionsMenu {
private val TAG = Log.tag(ConversationOptionsMenu::class.java)
/**
* MenuProvider implementation for the conversation options menu.
*/
@ -43,7 +51,12 @@ internal object ConversationOptionsMenu {
isInBubble
) = callback.getSnapshot()
if (isInMessageRequest && (recipient != null) && !recipient.isBlocked) {
if (recipient == null) {
Log.w(TAG, "Recipient is null, no menu")
return
}
if (isInMessageRequest && !recipient.isBlocked) {
if (isActiveGroup) {
menuInflater.inflate(R.menu.conversation_message_requests_group, menu)
}

View file

@ -1,18 +1,6 @@
/*
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation;
@ -101,6 +89,7 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.PendingIntentFlags;
import org.signal.core.util.StringUtil;
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.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
@ -115,6 +104,7 @@ import org.thoughtcrime.securesms.ShortcutLauncherActivity;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.audio.BluetoothVoiceNoteUtil;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
@ -160,6 +150,7 @@ import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChanged
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DraftTable.Draft;
@ -277,11 +268,10 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@ -299,6 +289,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil;
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.io.IOException;
@ -398,6 +389,7 @@ public class ConversationParentFragment extends Fragment
private ConversationFragment fragment;
private Button unblockButton;
private Stub<View> inviteButton;
private Stub<View> loggedOutStub;
private Button registerButton;
private InputAwareLayout container;
protected Stub<ReminderView> reminderView;
@ -411,8 +403,10 @@ public class ConversationParentFragment extends Fragment
private Stub<FrameLayout> voiceNotePlayerViewStub;
private View navigationBarBackground;
private AttachmentManager attachmentManager;
private AudioRecorder audioRecorder;
private AttachmentManager attachmentManager;
private BluetoothVoiceNoteUtil bluetoothVoiceNoteUtil;
private AudioRecorder audioRecorder;
private RecordingSession recordingSession;
private BroadcastReceiver securityUpdateReceiver;
private Stub<MediaKeyboard> emojiDrawerStub;
@ -508,6 +502,7 @@ public class ConversationParentFragment extends Fragment
voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), true);
voiceRecorderWakeLock = new VoiceRecorderWakeLock(requireActivity());
bluetoothVoiceNoteUtil = BluetoothVoiceNoteUtil.Companion.create(requireContext(), this::beginRecording, this::onBluetoothPermissionDenied);
// TODO [alex] LargeScreenSupport -- Should be removed once we move to multi-pane layout.
new FullscreenHelper(requireActivity()).showSystemUI();
@ -660,8 +655,9 @@ public class ConversationParentFragment extends Fragment
@Override
public void onDestroy() {
if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver);
if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver);
if (securityUpdateReceiver != null) requireActivity().unregisterReceiver(securityUpdateReceiver);
if (pinnedShortcutReceiver != null) requireActivity().unregisterReceiver(pinnedShortcutReceiver);
if (bluetoothVoiceNoteUtil != null) bluetoothVoiceNoteUtil.destroy();
super.onDestroy();
}
@ -1667,11 +1663,12 @@ public class ConversationParentFragment extends Fragment
Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue();
List<RecipientId> gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue();
if (UnauthorizedReminder.isEligible(context)) {
reminderView.get().showReminder(new UnauthorizedReminder(context));
} else if (ExpiredBuildReminder.isEligible()) {
if (ExpiredBuildReminder.isEligible()) {
reminderView.get().showReminder(new ExpiredBuildReminder(context));
reminderView.get().setOnActionClickListener(this::handleReminderAction);
} else if (UnauthorizedReminder.isEligible(context)) {
reminderView.get().showReminder(new UnauthorizedReminder(context));
reminderView.get().setOnActionClickListener(this::handleReminderAction);
} else if (ServiceOutageReminder.isEligible(context)) {
ApplicationDependencies.getJobManager().add(new ServiceOutageDetectionJob());
reminderView.get().showReminder(new ServiceOutageReminder(context));
@ -1714,6 +1711,8 @@ public class ConversationParentFragment extends Fragment
private void handleReminderAction(@IdRes int reminderActionId) {
if (reminderActionId == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
} else if (reminderActionId == R.id.reminder_action_re_register) {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()));
} else {
throw new IllegalArgumentException("Unknown ID: " + reminderActionId);
}
@ -1793,6 +1792,7 @@ public class ConversationParentFragment extends Fragment
attachmentKeyboardStub = ViewUtil.findStubById(view, R.id.attachment_keyboard_stub);
unblockButton = view.findViewById(R.id.unblock_button);
inviteButton = ViewUtil.findStubById(view, R.id.sms_export_stub);
loggedOutStub = ViewUtil.findStubById(view, R.id.logged_out_stub);
registerButton = view.findViewById(R.id.register_button);
container = view.findViewById(R.id.layout_container);
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub);
@ -2508,16 +2508,46 @@ public class ConversationParentFragment extends Fragment
return;
}
if (!conversationSecurityInfo.isPushAvailable() && isPushGroupConversation()) {
if (conversationSecurityInfo.isClientExpired() || conversationSecurityInfo.isUnauthorized()) {
unblockButton.setVisibility(View.GONE);
inputPanel.setHideForBlockedState(true);
inviteButton.setVisibility(View.GONE);
registerButton.setVisibility(View.GONE);
loggedOutStub.setVisibility(View.VISIBLE);
messageRequestBottomView.setVisibility(View.GONE);
int color = ContextCompat.getColor(requireContext(), recipient.hasWallpaper() ? R.color.wallpaper_bubble_color : R.color.signal_colorBackground);
loggedOutStub.get().setBackgroundColor(color);
WindowUtil.setNavigationBarColor(requireActivity(), color);
TextView message = loggedOutStub.get().findViewById(R.id.logged_out_message);
MaterialButton actionButton = loggedOutStub.get().findViewById(R.id.logged_out_button);
if (conversationSecurityInfo.isClientExpired()) {
message.setText(R.string.ExpiredBuildReminder_this_version_of_signal_has_expired);
actionButton.setText(R.string.ConversationFragment__update_build);
actionButton.setOnClickListener(v -> {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
});
} else if (conversationSecurityInfo.isUnauthorized()) {
message.setText(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device);
actionButton.setText(R.string.ConversationFragment__reregister_signal);
actionButton.setOnClickListener(v -> {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()));
});
}
} else if (!conversationSecurityInfo.isPushAvailable() && isPushGroupConversation()) {
unblockButton.setVisibility(View.GONE);
inputPanel.setHideForBlockedState(true);
inviteButton.setVisibility(View.GONE);
loggedOutStub.setVisibility(View.GONE);
registerButton.setVisibility(View.VISIBLE);
} else if (!conversationSecurityInfo.isPushAvailable() && (recipient.hasSmsAddress() || recipient.isMmsGroup())) {
unblockButton.setVisibility(View.GONE);
inputPanel.setHideForBlockedState(true);
inviteButton.setVisibility(SignalStore.account().isRegistered() ? View.VISIBLE : View.GONE);
registerButton.setVisibility(View.GONE);
loggedOutStub.setVisibility(View.GONE);
int color = ContextCompat.getColor(requireContext(), recipient.hasWallpaper() ? R.color.wallpaper_bubble_color : R.color.signal_colorBackground);
inviteButton.get().setBackgroundColor(color);
@ -3081,6 +3111,25 @@ public class ConversationParentFragment extends Fragment
@Override
public void onRecorderStarted() {
final AudioManagerCompat audioManager = ApplicationDependencies.getAndroidCallAudioManager();
if (audioManager.isBluetoothAvailable()) {
connectToBluetoothAndBeginRecording();
} else {
Log.d(TAG, "Recording from phone mic because no bluetooth devices were available.");
beginRecording();
}
}
private void connectToBluetoothAndBeginRecording() {
if (bluetoothVoiceNoteUtil != null) {
Log.d(TAG, "Initiating Bluetooth SCO connection...");
bluetoothVoiceNoteUtil.connectBluetoothScoConnection();
} else {
Log.e(TAG, "Unable to instantiate BluetoothVoiceNoteUtil.");
}
}
private Unit beginRecording() {
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
vibrator.vibrate(20);
@ -3090,6 +3139,18 @@ public class ConversationParentFragment extends Fragment
voiceNoteMediaController.pausePlayback();
recordingSession = new RecordingSession(audioRecorder.startRecording());
disposables.add(recordingSession);
return Unit.INSTANCE;
}
private Unit onBluetoothPermissionDenied() {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationParentFragment__bluetooth_permission_denied)
.setMessage(R.string.ConversationParentFragment__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
.setPositiveButton(R.string.ConversationParentFragment__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(requireContext())))
.setNegativeButton(R.string.ConversationParentFragment__not_now, null)
.show();
return Unit.INSTANCE;
}
@Override
@ -3101,6 +3162,7 @@ public class ConversationParentFragment extends Fragment
@Override
public void onRecorderFinished() {
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
voiceRecorderWakeLock.release();
updateToggleButtonState();
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
@ -3779,15 +3841,7 @@ public class ConversationParentFragment extends Fragment
.forMessageRecord(requireContext(), messageRecord)
.show(getChildFragmentManager());
} else if (messageRecord.hasFailedWithNetworkFailures()) {
new MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.conversation_activity__message_could_not_be_sent)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.conversation_activity__send, (dialog, which) -> {
SignalExecutors.BOUNDED.execute(() -> {
MessageSender.resend(requireContext(), messageRecord);
});
})
.show();
ConversationDialogs.INSTANCE.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord);
} else {
MessageDetailsFragment.create(messageRecord, recipient.getId()).show(getChildFragmentManager(), null);
}
@ -3849,7 +3903,7 @@ public class ConversationParentFragment extends Fragment
SimpleTask.run(() -> {
//noinspection CodeBlock2Expr
return SignalDatabase.messages().checkMessageExists(reactionDelegate.getMessageRecord());
return SignalDatabase.messages().messageExists(reactionDelegate.getMessageRecord());
}, messageExists -> {
if (!messageExists) {
reactionDelegate.hide();
@ -3930,7 +3984,7 @@ public class ConversationParentFragment extends Fragment
} else {
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).isViewOnce()) {
if (messageRecord.isMms() && messageRecord.isViewOnce()) {
Attachment attachment = new TombstoneAttachment(MediaUtil.VIEW_ONCE, true);
slideDeck = new SlideDeck();
slideDeck.addSlide(MediaUtil.getSlideForAttachment(requireContext(), attachment));

View file

@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.IOException;
import java.io.InputStream;
@ -64,8 +65,8 @@ public class ConversationRepository {
@WorkerThread
public @NonNull ConversationData getConversationData(long threadId, @NonNull Recipient conversationRecipient, int jumpToPosition) {
ThreadTable.ConversationMetadata metadata = SignalDatabase.threads().getConversationMetadata(threadId);
int threadSize = SignalDatabase.messages().getMessageCountForThread(threadId);
ThreadTable.ConversationMetadata metadata = SignalDatabase.threads().getConversationMetadata(threadId);
int threadSize = SignalDatabase.messages().getMessageCountForThread(threadId);
long lastSeen = metadata.getLastSeen();
int lastSeenPosition = 0;
long lastScrolled = metadata.getLastScrolled();
@ -117,7 +118,7 @@ public class ConversationRepository {
showUniversalExpireTimerUpdate = true;
}
return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate);
return new ConversationData(conversationRecipient, threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate);
}
public void markGiftBadgeRevealed(long messageId) {
@ -176,7 +177,9 @@ public class ConversationRepository {
Log.i(TAG, "Returning registered state...");
return new ConversationSecurityInfo(recipient.getId(),
registeredState == RecipientTable.RegisteredState.REGISTERED && signalEnabled,
true);
true,
SignalStore.misc().isClientDeprecated(),
TextSecurePreferences.isUnauthorizedReceived(context));
}).subscribeOn(Schedulers.io());
}
@ -192,7 +195,7 @@ public class ConversationRepository {
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, message.getThreadRecipient());
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
}

View file

@ -6,4 +6,6 @@ data class ConversationSecurityInfo(
val recipientId: RecipientId = RecipientId.UNKNOWN,
val isPushAvailable: Boolean = false,
val isInitialized: Boolean = false,
val isClientExpired: Boolean = false,
val isUnauthorized: Boolean = false
)

View file

@ -186,7 +186,13 @@ public class ConversationViewModel extends ViewModel {
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), conversationObserver);
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(data.getThreadId(), messageInsertObserver);
ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData, data.showUniversalExpireTimerMessage(), data.getThreadSize());
ConversationDataSource dataSource = new ConversationDataSource(context,
data.getThreadId(),
messageRequestData,
data.showUniversalExpireTimerMessage(),
data.getThreadSize(),
data.getThreadRecipient());
PagingConfig config = new PagingConfig.Builder().setPageSize(25)
.setBufferPages(2)
.setStartIndex(Math.max(startPosition, 0))

View file

@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
@ -5,12 +10,12 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.annimon.stream.Stream;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
@ -72,8 +77,8 @@ public class MarkReadHelper {
* @return A Present(Long) if there's a timestamp to proceed with, or Empty if this request should be ignored.
*/
@SuppressWarnings("resource")
public static @NonNull Optional<Long> getLatestTimestamp(@NonNull ConversationAdapter conversationAdapter,
@NonNull SmoothScrollingLinearLayoutManager layoutManager)
public static @NonNull Optional<Long> getLatestTimestamp(@NonNull ConversationAdapterBridge conversationAdapter,
@NonNull LinearLayoutManager layoutManager)
{
if (conversationAdapter.hasNoConversationMessages()) {
return Optional.empty();
@ -84,9 +89,9 @@ public class MarkReadHelper {
return Optional.empty();
}
ConversationMessage item = conversationAdapter.getItem(position);
ConversationMessage item = conversationAdapter.getConversationMessage(position);
if (item == null) {
item = conversationAdapter.getItem(position + 1);
item = conversationAdapter.getConversationMessage(position + 1);
}
if (item != null) {

View file

@ -92,7 +92,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper(), colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
setScheduledMessagesMode(true)
}
@ -147,24 +147,23 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
return callback
}
private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) {
private fun showScheduledMessageContextMenu(view: View, conversationMessage: ConversationMessage) {
SignalContextMenu.Builder(view, requireCoordinatorLayout())
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(getMenuActionItems(messageRecord))
.show(getMenuActionItems(conversationMessage))
}
private fun getMenuActionItems(messageRecord: MessageRecord): List<ActionItem> {
val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
private fun getMenuActionItems(message: ConversationMessage): List<ActionItem> {
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && message.messageRecord.body.isNotEmpty() }
val items: MutableList<ActionItem> = ArrayList()
items.add(ActionItem(R.drawable.symbol_trash_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) }))
items.add(ActionItem(R.drawable.symbol_trash_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(message.messageRecord) }))
if (canCopy) {
items.add(ActionItem(R.drawable.symbol_copy_android_24, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) }))
}
items.add(ActionItem(R.drawable.symbol_send_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) }))
items.add(ActionItem(R.drawable.symbol_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) }))
items.add(ActionItem(R.drawable.symbol_send_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(message.messageRecord) }))
items.add(ActionItem(R.drawable.symbol_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(message.messageRecord) }))
return items
}
@ -214,14 +213,14 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
try {
PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body)
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body, message.threadRecipient)
.getDisplayBody(requireContext())
}
} catch (e: IOException) {
Log.w(TAG, "Failed to read text slide data.")
}
}
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext())
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, message.threadRecipient).getDisplayBody(requireContext())
}
private fun deleteMessage(messageId: Long) {
@ -249,8 +248,8 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
callback.getConversationAdapterListener().onQuoteClicked(messageRecord)
}
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
showScheduledMessageContextMenu(view, messageRecord)
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) {
showScheduledMessageContextMenu(view, conversationMessage)
}
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {

View file

@ -4,10 +4,12 @@ import android.content.Context
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment]
@ -32,8 +34,9 @@ class ScheduledMessagesRepository {
@WorkerThread
private fun getScheduledMessagesSync(context: Context, threadId: Long): List<ConversationMessage> {
var scheduledMessages: List<MessageRecord> = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(threadId))
val attachmentHelper = ConversationDataSource.AttachmentHelper()
val attachmentHelper = AttachmentHelper()
attachmentHelper.addAll(scheduledMessages)
@ -42,7 +45,7 @@ class ScheduledMessagesRepository {
scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
val replies: List<ConversationMessage> = scheduledMessages
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it, threadRecipient) }
return replies
}

View file

@ -29,4 +29,22 @@ class AvatarColorPair private constructor(
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AvatarColorPair
if (foregroundColor != other.foregroundColor) return false
if (backgroundColor != other.backgroundColor) return false
return true
}
override fun hashCode(): Int {
var result = foregroundColor
result = 31 * result + backgroundColor
return result
}
}

View file

@ -112,7 +112,8 @@ class DraftRepository(
}
} ?: return@fromCallable null
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
}
}
@ -120,20 +121,21 @@ class DraftRepository(
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId))
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, threadRecipient)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient)
}
}

View file

@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect
import android.animation.Animator
@ -27,8 +32,8 @@ import com.airbnb.lottie.SimpleColorFilter
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.signal.core.util.SetUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationAdapter.PulseRequest
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge.PulseRequest
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
@ -118,7 +123,7 @@ class MultiselectItemDecoration(
}
private fun getCurrentSelection(parent: RecyclerView): Set<MultiselectPart> {
return (parent.adapter as ConversationAdapter).selectedItems
return (parent.adapter as ConversationAdapterBridge).selectedItems
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
@ -150,7 +155,7 @@ class MultiselectItemDecoration(
outRect.setEmpty()
updateChildOffsets(parent, view)
consumePulseRequest(parent.adapter as ConversationAdapter)
consumePulseRequest(parent.adapter as ConversationAdapterBridge)
}
/**
@ -158,7 +163,7 @@ class MultiselectItemDecoration(
*/
@Suppress("DEPRECATION")
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val adapter = parent.adapter as ConversationAdapter
val adapter = parent.adapter as ConversationAdapterBridge
if (adapter.selectedItems.isEmpty()) {
drawFocusShadeUnderIfNecessary(canvas, parent)
@ -221,7 +226,7 @@ class MultiselectItemDecoration(
* Draws the selected check or empty circle.
*/
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val adapter = parent.adapter as ConversationAdapter
val adapter = parent.adapter as ConversationAdapterBridge
if (adapter.selectedItems.isEmpty()) {
drawFocusShadeOverIfNecessary(canvas, parent)
}
@ -232,7 +237,7 @@ class MultiselectItemDecoration(
invalidateIfEnterExitAnimatorsAreRunning(parent)
}
private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapter) {
private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapterBridge) {
val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true
val multiselectChildren: Sequence<Multiselectable> = parent.children.filterIsInstance(Multiselectable::class.java)
@ -337,7 +342,7 @@ class MultiselectItemDecoration(
* called in getItemOffsets to ensure the gutter goes away when multiselect mode ends.
*/
private fun updateChildOffsets(parent: RecyclerView, child: View) {
val adapter = parent.adapter as ConversationAdapter
val adapter = parent.adapter as ConversationAdapterBridge
val isLtr = ViewUtil.isLtr(child)
val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation()
@ -542,8 +547,8 @@ class MultiselectItemDecoration(
}
}
private fun consumePulseRequest(adapter: ConversationAdapter) {
val pulseRequest = adapter.consumePulseRequest()
private fun consumePulseRequest(adapter: ConversationAdapterBridge) {
val pulseRequest: PulseRequest? = adapter.consumePulseRequest()
if (pulseRequest != null) {
val pulseColor = if (pulseRequest.isOutgoing) pulseOutgoingColor else pulseIncomingColor
pulseRequestAnimators[pulseRequest]?.cancel()

View file

@ -125,7 +125,7 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
if (textSlideUri != null) {
PartAuthority.getAttachmentStream(context, textSlideUri).use {
val body = StreamUtil.readFullyAsString(it)
val msg = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, mediaMessage, body)
val msg = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, mediaMessage, body, conversationMessage.threadRecipient)
builder.withDraftText(msg.getDisplayBody(context).toString())
}
} else {

View file

@ -72,7 +72,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper(), colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
}

View file

@ -4,9 +4,10 @@ import android.app.Application
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
@ -56,8 +57,9 @@ class MessageQuotesRepository {
var replyRecords: List<MessageRecord> = SignalDatabase.messages.getAllMessagesThatQuote(rootMessageId)
val reactionHelper = ConversationDataSource.ReactionHelper()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
val reactionHelper = ReactionHelper()
val attachmentHelper = AttachmentHelper()
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(originalRecord.threadId))
reactionHelper.addAll(replyRecords)
attachmentHelper.addAll(replyRecords)
@ -77,13 +79,13 @@ class MessageQuotesRepository {
replyRecord
}
}
.map { ConversationMessageFactory.createWithUnresolvedData(application, it) }
.map { ConversationMessageFactory.createWithUnresolvedData(application, it, threadRecipient) }
if (originalRecord.isPaymentNotification) {
originalRecord = SignalDatabase.payments.updateMessageWithPayment(originalRecord)
}
originalRecord = ConversationDataSource.ReactionHelper()
originalRecord = ReactionHelper()
.apply {
add(originalRecord)
fetchReactions()
@ -91,7 +93,7 @@ class MessageQuotesRepository {
.buildUpdatedModels(listOf(originalRecord))
.get(0)
originalRecord = ConversationDataSource.AttachmentHelper()
originalRecord = AttachmentHelper()
.apply {
add(originalRecord)
fetchAttachments()
@ -99,7 +101,7 @@ class MessageQuotesRepository {
.buildUpdatedModels(ApplicationDependencies.getApplication(), listOf(originalRecord))
.get(0)
val originalMessage: ConversationMessage = ConversationMessageFactory.createWithUnresolvedData(application, originalRecord, false)
val originalMessage: ConversationMessage = ConversationMessageFactory.createWithUnresolvedData(application, originalRecord, false, threadRecipient)
return replies + originalMessage
}

View file

@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@ -68,7 +69,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
GlideApp.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
conversationRecipient,
conversationRecipient.hasWallpaper(),
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.EXTRA_CONDENSED)
@ -119,7 +120,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit

View file

@ -2,11 +2,12 @@ package org.thoughtcrime.securesms.conversation.ui.edit
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
object EditMessageHistoryRepository {
@ -35,14 +36,20 @@ object EditMessageHistoryRepository {
.getMessageEditHistory(messageId)
.toList()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
val attachmentHelper = AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
if (records.isEmpty()) {
return emptyList()
}
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(records[0].threadId))
return attachmentHelper
.buildUpdatedModels(context, records)
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it, threadRecipient) }
}
}

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