mirror of
https://github.com/mollyim/mollyim-insider-android.git
synced 2025-05-13 05:40:53 +01:00
Merge tag 'v6.20.3' into molly-6.20
This commit is contained in:
commit
bd2bc4d005
329 changed files with 16877 additions and 6277 deletions
6
.idea/copyright/Signal.xml
Normal file
6
.idea/copyright/Signal.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright &#36;today.year Signal Messenger, LLC SPDX-License-Identifier: AGPL-3.0-only" />
|
||||
<option name="myName" value="Signal" />
|
||||
</copyright>
|
||||
</component>
|
7
.idea/copyright/profiles_settings.xml
Normal file
7
.idea/copyright/profiles_settings.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<component name="CopyrightManager">
|
||||
<settings>
|
||||
<module2copyright>
|
||||
<element module="All" copyright="Signal" />
|
||||
</module2copyright>
|
||||
</settings>
|
||||
</component>
|
9
.idea/fileTemplates/internal/AnnotationType.java
Normal file
9
.idea/fileTemplates/internal/AnnotationType.java
Normal 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} {
|
||||
}
|
9
.idea/fileTemplates/internal/Class.java
Normal file
9
.idea/fileTemplates/internal/Class.java
Normal 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} {
|
||||
}
|
9
.idea/fileTemplates/internal/Enum.java
Normal file
9
.idea/fileTemplates/internal/Enum.java
Normal 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} {
|
||||
}
|
9
.idea/fileTemplates/internal/Interface.java
Normal file
9
.idea/fileTemplates/internal/Interface.java
Normal 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} {
|
||||
}
|
11
.idea/fileTemplates/internal/Kotlin Class.kt
Normal file
11
.idea/fileTemplates/internal/Kotlin Class.kt
Normal 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} {
|
||||
}
|
11
.idea/fileTemplates/internal/Kotlin Enum.kt
Normal file
11
.idea/fileTemplates/internal/Kotlin Enum.kt
Normal 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} {
|
||||
}
|
9
.idea/fileTemplates/internal/Kotlin File.kt
Normal file
9
.idea/fileTemplates/internal/Kotlin File.kt
Normal 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")
|
11
.idea/fileTemplates/internal/Kotlin Interface.kt
Normal file
11
.idea/fileTemplates/internal/Kotlin Interface.kt
Normal 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
152
LICENSE
|
@ -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/>.
|
|
@ -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\", " +
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)}
|
|
@ -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
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}")
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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>()
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
public interface OnAudioOutputChangedListener {
|
||||
void audioOutputChanged(WebRtcAudioOutput audioOutput);
|
||||
void audioOutputChanged(WebRtcAudioDevice device);
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -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?)
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue