Merge tag 'v7.1.2' into molly-7.1

Closes #293
This commit is contained in:
Oscar Mira 2024-03-13 19:48:29 +01:00
commit 61c4f1a6e7
429 changed files with 17614 additions and 7801 deletions

View file

@ -1,4 +1,4 @@
# Signal Android
# Signal Android
Signal is a simple, powerful, and secure messenger.
@ -54,7 +54,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2022 Signal
Copyright 2013-2024 Signal Messenger, LLC
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html

View file

@ -19,8 +19,8 @@ apply {
from("fix-profm.gradle")
}
val canonicalVersionCode = 1396
val canonicalVersionName = "7.0.2"
val canonicalVersionCode = 1399
val canonicalVersionName = "7.1.2"
val mollyRevision = 1
val postFixSize = 100
@ -187,7 +187,6 @@ android {
buildConfigField("int", "CONTENT_PROXY_PORT", "443")
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
buildConfigField("String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\"")
buildConfigField("String", "SVR2_MRENCLAVE_DEPRECATED", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"a6622ad4656e1abcd0bc0ff17c229477747d2ded0495c4ebee7ed35c1789fa97\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0I=\"")
@ -198,6 +197,7 @@ android {
buildConfigField("String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\"")
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"")
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.PRODUCTION")
// MOLLY: Rely on the built-in variables FLAVOR and BUILD_TYPE instead of BUILD_*_TYPE
buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"")
@ -333,7 +333,6 @@ android {
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE_DEPRECATED", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"acb1973aa0bbbd14b3b4e06f145497d948fd4a98efc500fcce363b3b743ec482\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCM=\"")
@ -342,6 +341,7 @@ android {
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"")
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")

View file

@ -40,4 +40,18 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
LogDatabase.getInstance(this).logs.trimToSize()
}
}
override fun beginJobLoop() = Unit
/**
* Some of the jobs can interfere with some of the instrumentation tests.
*
* For example, we may try to create a release channel recipient while doing
* an import/backup test.
*
* This can be used to start the job loop if needed for tests that rely on it.
*/
fun beginJobLoopForTests() {
super.beginJobLoop()
}
}

View file

@ -0,0 +1,604 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import okio.ByteString.Companion.toByteString
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Call
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.Group
import org.thoughtcrime.securesms.backup.v2.proto.Recipient
import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes
import org.thoughtcrime.securesms.backup.v2.proto.Self
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.ArrayList
import java.util.UUID
import kotlin.random.Random
import kotlin.time.Duration.Companion.days
/**
* Test the import and export of message backup frames to make sure what
* goes in, comes out.
*/
class ImportExportTest {
companion object {
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val SELF_E164 = "+10000000000"
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
val defaultBackupInfo = BackupInfo(version = 1L, backupTimeMs = 123456L)
val selfRecipient = Recipient(id = 1, self = Self())
val releaseNotes = Recipient(id = 2, releaseNotes = ReleaseNotes())
val standardAccountData = AccountData(
profileKey = SELF_PROFILE_KEY.serialize().toByteString(),
username = "testusername",
usernameLink = null,
givenName = "Peter",
familyName = "Parker",
avatarUrlPath = "https://example.com/",
subscriberId = SubscriberId.generate().bytes.toByteString(),
subscriberCurrencyCode = "USD",
subscriptionManuallyCancelled = true,
accountSettings = AccountData.AccountSettings(
readReceipts = true,
sealedSenderIndicators = true,
typingIndicators = true,
linkPreviews = true,
notDiscoverableByPhoneNumber = true,
preferContactAvatars = true,
universalExpireTimer = 42,
displayBadgesOnProfile = true,
keepMutedChatsArchived = true,
hasSetMyStoriesPrivacy = true,
hasViewedOnboardingStory = true,
storiesDisabled = true,
storyViewReceiptsEnabled = true,
hasSeenGroupStoryEducationSheet = true,
hasCompletedUsernameOnboarding = true,
phoneNumberSharingMode = AccountData.PhoneNumberSharingMode.EVERYBODY,
preferredReactionEmoji = listOf("a", "b", "c")
)
)
/**
* When using standardFrames you must start recipient ids at 3.
*/
private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, releaseNotes)
}
@Before
fun setup() {
SignalStore.account().setE164(SELF_E164)
SignalStore.account().setAci(SELF_ACI)
SignalStore.account().setPni(SELF_PNI)
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
}
@Test
fun accountAndSelf() {
importExport(*standardFrames)
}
@Test
fun individualRecipients() {
importExport(
*standardFrames,
Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "coolusername",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 4,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501235,
blocked = true,
hidden = true,
registered = Contact.Registered.NOT_REGISTERED,
unregisteredTimestamp = 1234568927398L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = false,
profileGivenName = "Peter",
profileFamilyName = "Kim",
hideStory = true
)
)
)
}
@Test
fun groupRecipients() {
importExport(
*standardFrames,
Recipient(
id = 3,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.ENABLED,
name = "Cool test group"
)
),
Recipient(
id = 4,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = false,
hideStory = false,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
)
)
)
}
@Test
fun distributionListRecipients() {
importExport(
*standardFrames,
Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "coolusername",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 4,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501235,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Peter",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 5,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501236,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Father",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 6,
distributionList = DistributionList(
name = "Kim Family",
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
allowReplies = true,
deletionTimestamp = 0L,
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
memberRecipientIds = listOf(3, 4, 5)
)
)
)
}
@Test
fun deletedDistributionList() {
val alexa = Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "coolusername",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
profileFamilyName = "Kim",
hideStory = true
)
)
import(
*standardFrames,
alexa,
Recipient(
id = 6,
distributionList = DistributionList(
name = "Deleted list",
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
allowReplies = true,
deletionTimestamp = 12345L,
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
memberRecipientIds = listOf(3)
)
)
)
val exported = export()
val expected = exportFrames(
*standardFrames,
alexa
)
compare(expected, exported)
}
@Test
fun chatThreads() {
importExport(
*standardFrames,
Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "coolusername",
e164 = 141255501234,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 4,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
)
),
Chat(
id = 1,
recipientId = 3,
archived = true,
pinnedOrder = 1,
expirationTimerMs = 1.days.inWholeMilliseconds,
muteUntilMs = System.currentTimeMillis(),
markedUnread = true,
dontNotifyForMentionsIfMuted = true,
wallpaper = null
)
)
}
@Test
fun calls() {
val individualCalls = ArrayList<Call>()
val groupCalls = ArrayList<Call>()
val states = arrayOf(Call.State.MISSED, Call.State.COMPLETED, Call.State.DECLINED_BY_USER, Call.State.DECLINED_BY_NOTIFICATION_PROFILE)
val types = arrayOf(Call.Type.VIDEO_CALL, Call.Type.AD_HOC_CALL, Call.Type.AUDIO_CALL)
var id = 1L
var timestamp = 12345L
for (state in states) {
for (type in types) {
individualCalls.add(
Call(
callId = id++,
conversationRecipientId = 3,
type = type,
state = state,
timestamp = timestamp++,
ringerRecipientId = 3,
outgoing = true
)
)
individualCalls.add(
Call(
callId = id++,
conversationRecipientId = 3,
type = type,
state = state,
timestamp = timestamp++,
ringerRecipientId = selfRecipient.id,
outgoing = false
)
)
}
groupCalls.add(
Call(
callId = id++,
conversationRecipientId = 4,
type = Call.Type.GROUP_CALL,
state = state,
timestamp = timestamp++,
ringerRecipientId = 3,
outgoing = true
)
)
groupCalls.add(
Call(
callId = id++,
conversationRecipientId = 4,
type = Call.Type.GROUP_CALL,
state = state,
timestamp = timestamp++,
ringerRecipientId = selfRecipient.id,
outgoing = false
)
)
}
importExport(
*standardFrames,
Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "coolusername",
e164 = 141255501234,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 4,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
)
),
*individualCalls.toArray(),
*groupCalls.toArray()
)
}
/**
* Export passed in frames as a backup. Does not automatically include
* any standard frames (e.g. backup header).
*/
private fun exportFrames(vararg objects: Any): ByteArray {
val outputStream = ByteArrayOutputStream()
val writer = EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
writer.use {
for (obj in objects) {
when (obj) {
is BackupInfo -> writer.write(obj)
is AccountData -> writer.write(Frame(account = obj))
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is Call -> writer.write(Frame(call = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
}
}
return outputStream.toByteArray()
}
/**
* Exports the passed in frames as a backup and then attempts to
* import them.
*/
private fun import(vararg objects: Any) {
val importData = exportFrames(*objects)
BackupRepository.import(length = importData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(importData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
}
/**
* Export our current database as a backup.
*/
private fun export() = BackupRepository.export()
/**
* Imports the passed in frames and then exports them.
*
* It will do a comparison to assert that the import and export
* are equal.
*/
private fun importExport(vararg objects: Any) {
val outputStream = ByteArrayOutputStream()
val writer = EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
writer.use {
for (obj in objects) {
when (obj) {
is BackupInfo -> writer.write(obj)
is AccountData -> writer.write(Frame(account = obj))
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is Call -> writer.write(Frame(call = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
}
}
val importData = outputStream.toByteArray()
BackupRepository.import(length = importData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(importData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
val export = export()
compare(importData, export)
}
private fun compare(import: ByteArray, export: ByteArray) {
val selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)
val framesImported = readAllFrames(import, selfData)
val framesExported = readAllFrames(export, selfData)
compareFrameList(framesImported, framesExported)
}
private fun compareFrameList(framesImported: List<Frame>, framesExported: List<Frame>) {
val accountExported = ArrayList<AccountData>()
val accountImported = ArrayList<AccountData>()
val recipientsImported = ArrayList<Recipient>()
val recipientsExported = ArrayList<Recipient>()
val chatsImported = ArrayList<Chat>()
val chatsExported = ArrayList<Chat>()
val chatItemsImported = ArrayList<ChatItem>()
val chatItemsExported = ArrayList<ChatItem>()
val callsImported = ArrayList<Call>()
val callsExported = ArrayList<Call>()
val stickersImported = ArrayList<StickerPack>()
val stickersExported = ArrayList<StickerPack>()
for (f in framesImported) {
when {
f.account != null -> accountExported.add(f.account!!)
f.recipient != null -> recipientsImported.add(f.recipient!!)
f.chat != null -> chatsImported.add(f.chat!!)
f.chatItem != null -> chatItemsImported.add(f.chatItem!!)
f.call != null -> callsImported.add(f.call!!)
f.stickerPack != null -> stickersImported.add(f.stickerPack!!)
}
}
for (f in framesExported) {
when {
f.account != null -> accountImported.add(f.account!!)
f.recipient != null -> recipientsExported.add(f.recipient!!)
f.chat != null -> chatsExported.add(f.chat!!)
f.chatItem != null -> chatItemsExported.add(f.chatItem!!)
f.call != null -> callsExported.add(f.call!!)
f.stickerPack != null -> stickersExported.add(f.stickerPack!!)
}
}
prettyAssertEquals(accountImported, accountExported)
prettyAssertEquals(recipientsImported, recipientsExported) { it.id }
prettyAssertEquals(chatsImported, chatsExported) { it.id }
prettyAssertEquals(chatItemsImported, chatItemsExported)
prettyAssertEquals(callsImported, callsExported) { it.callId }
prettyAssertEquals(stickersImported, stickersExported) { it.packId }
}
private fun <T> prettyAssertEquals(import: List<T>, export: List<T>) {
Assert.assertEquals(import.size, export.size)
import.zip(export).forEach { (a1, a2) ->
if (a1 != a2) {
Assert.fail("Items do not match: \n $a1 \n $a2")
}
}
}
private fun <T, R : Comparable<R>> prettyAssertEquals(import: List<T>, export: List<T>, selector: (T) -> R?) {
if (import.size != export.size) {
var msg = StringBuilder()
for (i in import) {
msg.append(i)
msg.append("\n")
}
for (i in export) {
msg.append(i)
msg.append("\n")
}
Assert.fail(msg.toString())
}
Assert.assertEquals(import.size, export.size)
val sortedImport = import.sortedBy(selector)
val sortedExport = export.sortedBy(selector)
prettyAssertEquals(sortedImport, sortedExport)
}
private fun readAllFrames(import: ByteArray, selfData: BackupRepository.SelfData): List<Frame> {
val inputFactory = { ByteArrayInputStream(import) }
val frameReader = EncryptedBackupReader(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = selfData.aci,
streamLength = import.size.toLong(),
dataStream = inputFactory
)
val frames = ArrayList<Frame>()
while (frameReader.hasNext()) {
frames.add(frameReader.next())
}
return frames
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.util.UUID
import kotlin.random.Random
object TestRecipientUtils {
private var upperGenAci = 13131313L
private var lowerGenAci = 0L
private var upperGenPni = 12121212L
private var lowerGenPni = 0L
private var groupMasterKeyRandom = Random(12345)
fun generateProfileKey(): ByteArray {
return ProfileKeyUtil.createNew().serialize()
}
fun nextPni(): ByteArray {
synchronized(this) {
lowerGenPni++
var uuid = UUID(upperGenPni, lowerGenPni)
return uuid.toByteArray()
}
}
fun nextAci(): ByteArray {
synchronized(this) {
lowerGenAci++
var uuid = UUID(upperGenAci, lowerGenAci)
return uuid.toByteArray()
}
}
fun generateGroupMasterKey(): ByteArray {
val masterKey = ByteArray(32)
groupMasterKeyRandom.nextBytes(masterKey)
return masterKey
}
}

View file

@ -57,7 +57,7 @@ class RecipientTableTest {
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results = SignalDatabase.recipients.querySignalContacts("Hidden", false)!!
val results = SignalDatabase.recipients.querySignalContacts(RecipientTable.ContactSearchQuery("Hidden", false))!!
assertEquals(0, results.count)
}
@ -128,7 +128,7 @@ class RecipientTableTest {
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
val results = SignalDatabase.recipients.querySignalContacts("Blocked", false)!!
val results = SignalDatabase.recipients.querySignalContacts(RecipientTable.ContactSearchQuery("Blocked", false))!!
assertEquals(0, results.count)
}

View file

@ -776,6 +776,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+aci, pni+aci provided, change number") {
given(E164_A, PNI_A, null)
given(E164_B, null, ACI_A)
process(null, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
expectChangeNumberEvent()
}
test("merge, e164 + pni reassigned, aci abandoned") {
given(E164_A, PNI_A, ACI_A)
given(E164_B, PNI_B, ACI_B)

View file

@ -37,7 +37,7 @@ import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
*
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess].
*/
class InstrumentationApplicationDependencyProvider(application: Application, default: ApplicationDependencyProvider) : ApplicationDependencies.Provider by default {
class InstrumentationApplicationDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : ApplicationDependencies.Provider by default {
private val serviceTrustStore: TrustStore
private val uncensoredConfiguration: SignalServiceConfiguration

View file

@ -138,7 +138,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View file

@ -1029,6 +1029,16 @@
android:theme="@style/Theme.Signal.WallpaperCropper"
android:exported="false"/>
<activity android:name=".components.settings.app.usernamelinks.main.UsernameQrImageSelectionActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.DarkNoActionBar"
android:exported="false"/>
<activity android:name=".components.settings.app.usernamelinks.main.UsernameQrScannerActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity android:name=".reactions.edit.EditReactionsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 100 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -216,7 +216,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(this::initializeCleanup)
.addNonBlocking(this::initializeGlideCodecs)
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
.addNonBlocking(this::beginJobLoop)
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
.addNonBlocking(this::ensureProfileUploaded)
@ -599,6 +599,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
@VisibleForTesting
protected void beginJobLoop() {
ApplicationDependencies.getJobManager().beginJobLoop();
}
@WorkerThread
private void initializeBlobProvider() {
BlobProvider.getInstance().initialize(this);

View file

@ -890,11 +890,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return ContactSearchConfiguration.build(builder -> {
builder.setQuery(contactSearchState.getQuery());
if (newConversationCallback != null) {
if (newConversationCallback != null && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (findByCallback != null) {
if (findByCallback != null && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
@ -913,12 +913,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
));
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf,
transportType,
newCallCallback == null && findByCallback == null,
!hideHeader,
null,
!hideLetterHeaders()
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
));
}
@ -944,7 +946,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
builder.username(newRowMode);
}
if (newCallCallback != null || newConversationCallback != null) {
if ((newCallCallback != null || newConversationCallback != null) && !hasQuery) {
addMoreSection(builder);
builder.withEmptyState(emptyBuilder -> {
emptyBuilder.addSection(ContactSearchConfiguration.Section.Empty.INSTANCE);

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
@ -49,6 +50,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
private static final String TAG = Log.tag(DeviceActivity.class);
private static final String EXTRA_DIRECT_TO_SCANNER = "add";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
@ -57,6 +60,13 @@ public class DeviceActivity extends PassphraseRequiredActivity
private DeviceLinkFragment deviceLinkFragment;
private MenuItem cameraSwitchItem = null;
public static Intent getIntentForScanner(Context context) {
Intent intent = new Intent(context, DeviceActivity.class);
intent.putExtra(EXTRA_DIRECT_TO_SCANNER, true);
return intent;
}
@Override
public void onPreCreate() {
dynamicTheme.onCreate(this);
@ -80,7 +90,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
this.deviceListFragment.setAddDeviceButtonListener(this);
this.deviceAddFragment.setScanListener(this);
if (getIntent().getBooleanExtra("add", false)) {
if (getIntent().getBooleanExtra(EXTRA_DIRECT_TO_SCANNER, false)) {
initFragment(R.id.fragment_container, deviceAddFragment, dynamicLanguage.getCurrentLocale());
} else {
initFragment(R.id.fragment_container, deviceListFragment, dynamicLanguage.getCurrentLocale());

View file

@ -26,9 +26,7 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
.setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner))
.setPositiveButton(R.string.DeviceProvisioningActivity_continue, (dialog1, which) -> {
Intent intent = new Intent(DeviceProvisioningActivity.this, DeviceActivity.class);
intent.putExtra("add", true);
startActivity(intent);
startActivity(DeviceActivity.getIntentForScanner(this));
finish();
})
.setNegativeButton(android.R.string.cancel, (dialog12, which) -> {

View file

@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientRepository;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
import org.thoughtcrime.securesms.util.CommunicationActions;
@ -120,6 +121,8 @@ public class NewConversationActivity extends ContactSelectionActivity
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
boolean smsSupported = false;
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
@ -130,33 +133,19 @@ public class NewConversationActivity extends ContactSelectionActivity
AlertDialog progress = SimpleProgressDialog.show(this);
SimpleTask.run(getLifecycle(), () -> {
Recipient resolved = Recipient.external(this, number);
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
try {
ContactDiscovery.refresh(this, resolved, false, TimeUnit.SECONDS.toMillis(10));
resolved = Recipient.resolved(resolved.getId());
} catch (IOException e) {
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
return null;
}
}
return resolved;
}, resolved -> {
SimpleTask.run(getLifecycle(), () -> RecipientRepository.lookupNewE164(this, number), result -> {
progress.dismiss();
if (resolved != null) {
if (resolved.isRegistered() && resolved.hasServiceId()) {
if (result instanceof RecipientRepository.LookupResult.Success) {
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
if (smsSupported || resolved.isRegistered() && resolved.hasServiceId()) {
launch(resolved);
} else {
new MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_registered_with_signal, resolved.getDisplayName(this)))
.setPositiveButton(android.R.string.ok, null)
.show();
}
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
new MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number))
.setPositiveButton(android.R.string.ok, null)
.show();
} else {
new MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
@ -164,6 +153,8 @@ public class NewConversationActivity extends ContactSelectionActivity
.show();
}
});
} else if (smsSupported) {
launch(Recipient.external(this, number));
}
}

View file

@ -22,6 +22,9 @@ class DatabaseAttachment : Attachment {
@JvmField
val hasData: Boolean
@JvmField
val dataHash: String?
private val hasThumbnail: Boolean
val displayOrder: Int
@ -53,7 +56,8 @@ class DatabaseAttachment : Attachment {
audioHash: AudioHash?,
transformProperties: TransformProperties?,
displayOrder: Int,
uploadTimestamp: Long
uploadTimestamp: Long,
dataHash: String?
) : super(
contentType = contentType!!,
transferState = transferProgress,
@ -81,6 +85,7 @@ class DatabaseAttachment : Attachment {
this.attachmentId = attachmentId
this.mmsId = mmsId
this.hasData = hasData
this.dataHash = dataHash
this.hasThumbnail = hasThumbnail
this.displayOrder = displayOrder
}
@ -88,6 +93,7 @@ class DatabaseAttachment : Attachment {
constructor(parcel: Parcel) : super(parcel) {
attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!!
hasData = ParcelUtil.readBoolean(parcel)
dataHash = parcel.readString()
hasThumbnail = ParcelUtil.readBoolean(parcel)
mmsId = parcel.readLong()
displayOrder = parcel.readInt()
@ -97,6 +103,7 @@ class DatabaseAttachment : Attachment {
super.writeToParcel(dest, flags)
dest.writeParcelable(attachmentId, 0)
ParcelUtil.writeBoolean(dest, hasData)
dest.writeString(dataHash)
ParcelUtil.writeBoolean(dest, hasThumbnail)
dest.writeLong(mmsId)
dest.writeInt(displayOrder)

View file

@ -5,10 +5,16 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.MessageBackup
import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
import org.signal.libsignal.messagebackup.MessageBackupKey
import org.signal.libsignal.protocol.ServiceId.Aci
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
@ -16,6 +22,7 @@ import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
@ -23,12 +30,22 @@ import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlin.time.Duration.Companion.milliseconds
@ -36,6 +53,7 @@ import kotlin.time.Duration.Companion.milliseconds
object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
private const val VERSION = 1L
fun export(plaintext: Boolean = false): ByteArray {
val eventTimer = EventTimer()
@ -52,7 +70,15 @@ object BackupRepository {
)
}
val exportState = ExportState()
writer.use {
writer.write(
BackupInfo(
version = VERSION,
backupTimeMs = System.currentTimeMillis()
)
)
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
@ -61,12 +87,12 @@ object BackupRepository {
eventTimer.emit("account")
}
RecipientBackupProcessor.export {
RecipientBackupProcessor.export(exportState) {
writer.write(it)
eventTimer.emit("recipient")
}
ChatBackupProcessor.export { frame ->
ChatBackupProcessor.export(exportState) { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
@ -76,7 +102,7 @@ object BackupRepository {
eventTimer.emit("call")
}
ChatItemBackupProcessor.export { frame ->
ChatItemBackupProcessor.export(exportState) { frame ->
writer.write(frame)
eventTimer.emit("message")
}
@ -88,6 +114,13 @@ object BackupRepository {
return outputStream.toByteArray()
}
fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult {
val masterKey = SignalStore.svr().getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
return MessageBackup.validate(key, inputStreamFactory, length)
}
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
val eventTimer = EventTimer()
@ -102,6 +135,15 @@ object BackupRepository {
)
}
val header = frameReader.getHeader()
if (header == null) {
Log.e(TAG, "Backup is missing header!")
return
} else if (header.version > VERSION) {
Log.e(TAG, "Backup version is newer than we understand: ${header.version}")
return
}
// Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
@ -117,6 +159,7 @@ object BackupRepository {
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
eventTimer.emit("setup")
val backupState = BackupState()
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
@ -161,6 +204,14 @@ object BackupRepository {
}
}
val groups = SignalDatabase.groups.getGroups()
while (groups.hasNext()) {
val group = groups.next()
if (group.id.isV2) {
ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(group.id as GroupId.V2))
}
}
Log.d(TAG, "import() ${eventTimer.stop().summary}")
}
@ -230,6 +281,77 @@ object BackupRepository {
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
}
/**
* Returns an object with details about the remote backup state.
*/
fun debugGetArchivedMediaState(): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential ->
api.debugGetUploadedMediaItemMetadata(backupKey, credential)
}
}
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential ->
api.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
item = attachment.toArchiveMediaRequest(backupKey)
)
}
.also { Log.i(TAG, "backupMediaResult: $it") }
}
fun archiveMedia(attachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResponse> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential ->
api.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
items = attachments.map { it.toArchiveMediaRequest(backupKey) }
)
}
.also { Log.i(TAG, "backupMediaResult: $it") }
}
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = attachments.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = 3, // TODO [cody] store and reuse backup cdn returned from copy/move call
mediaId = backupKey.deriveMediaId(Base64.decode(it.dataHash!!)).toString()
)
}
return getAuthCredential()
.then { credential ->
api.deleteArchivedMedia(
backupKey = backupKey,
serviceCredential = credential,
mediaToDelete = mediaToDelete
)
}
.also { Log.i(TAG, "deleteBackupMediaResult: $it") }
}
/**
* Retrieves an auth credential, preferring a cached value if available.
*/
@ -257,6 +379,26 @@ object BackupRepository {
val e164: String,
val profileKey: ProfileKey
)
private fun DatabaseAttachment.toArchiveMediaRequest(backupKey: BackupKey): ArchiveMediaRequest {
val mediaSecrets = backupKey.deriveMediaSecrets(Base64.decode(dataHash!!))
return ArchiveMediaRequest(
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
cdn = cdnNumber,
key = remoteLocation!!
),
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
mediaId = mediaSecrets.id.toString(),
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
encryptionKey = Base64.encodeWithPadding(mediaSecrets.cipherKey),
iv = Base64.encodeWithPadding(mediaSecrets.iv)
)
}
}
class ExportState {
val recipientIds = HashSet<Long>()
val threadIds = HashSet<Long>()
}
class BackupState {

View file

@ -39,17 +39,12 @@ fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupStat
Call.Type.UNKNOWN_TYPE -> return
}
val event = when (call.event) {
Call.Event.DELETE -> CallTable.Event.DELETE
Call.Event.JOINED -> CallTable.Event.JOINED
Call.Event.GENERIC_GROUP_CALL -> CallTable.Event.GENERIC_GROUP_CALL
Call.Event.DECLINED -> CallTable.Event.DECLINED
Call.Event.ACCEPTED -> CallTable.Event.ACCEPTED
Call.Event.MISSED -> CallTable.Event.MISSED
Call.Event.OUTGOING_RING -> CallTable.Event.OUTGOING_RING
Call.Event.OUTGOING -> CallTable.Event.ONGOING
Call.Event.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED
Call.Event.UNKNOWN_EVENT -> return
val event = when (call.state) {
Call.State.MISSED -> CallTable.Event.MISSED
Call.State.COMPLETED -> CallTable.Event.ACCEPTED
Call.State.DECLINED_BY_USER -> CallTable.Event.DECLINED
Call.State.DECLINED_BY_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
Call.State.UNKNOWN_EVENT -> return
}
val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING
@ -62,7 +57,8 @@ fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupStat
CallTable.TYPE to CallTable.Type.serialize(type),
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
CallTable.EVENT to CallTable.Event.serialize(event),
CallTable.TIMESTAMP to call.timestamp
CallTable.TIMESTAMP to call.timestamp,
CallTable.RINGER to if (call.ringerRecipientId != null) backupState.backupToLocalRecipientId[call.ringerRecipientId]?.toLong() else null
)
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
@ -102,18 +98,18 @@ class CallLogIterator(private val cursor: Cursor) : Iterator<BackupCall?>, Close
},
timestamp = cursor.requireLong(CallTable.TIMESTAMP),
ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER),
event = when (event) {
CallTable.Event.ONGOING -> Call.Event.OUTGOING
CallTable.Event.OUTGOING_RING -> Call.Event.OUTGOING_RING
CallTable.Event.ACCEPTED -> Call.Event.ACCEPTED
CallTable.Event.DECLINED -> Call.Event.DECLINED
CallTable.Event.GENERIC_GROUP_CALL -> Call.Event.GENERIC_GROUP_CALL
CallTable.Event.JOINED -> Call.Event.JOINED
CallTable.Event.MISSED,
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.Event.MISSED
CallTable.Event.DELETE -> Call.Event.DELETE
CallTable.Event.RINGING -> Call.Event.UNKNOWN_EVENT
CallTable.Event.NOT_ACCEPTED -> Call.Event.NOT_ACCEPTED
state = when (event) {
CallTable.Event.ONGOING -> Call.State.COMPLETED
CallTable.Event.OUTGOING_RING -> Call.State.COMPLETED
CallTable.Event.ACCEPTED -> Call.State.COMPLETED
CallTable.Event.DECLINED -> Call.State.DECLINED_BY_USER
CallTable.Event.GENERIC_GROUP_CALL -> Call.State.COMPLETED
CallTable.Event.JOINED -> Call.State.COMPLETED
CallTable.Event.MISSED -> Call.State.MISSED
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.State.DECLINED_BY_NOTIFICATION_PROFILE
CallTable.Event.DELETE -> Call.State.COMPLETED
CallTable.Event.RINGING -> Call.State.MISSED
CallTable.Event.NOT_ACCEPTED -> Call.State.MISSED
}
)
}

View file

@ -9,6 +9,7 @@ import android.database.Cursor
import com.annimon.stream.Stream
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decode
import org.signal.core.util.Base64.decodeOrThrow
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBlob
@ -40,11 +41,16 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@ -99,6 +105,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
val mentionsById: Map<Long, List<Mention>> = SignalDatabase.mentions.getMentionsForMessages(records.keys)
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
for ((id, record) in records) {
@ -154,6 +161,26 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
)
}
MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> {
val groupChange = record.messageExtras?.gv2UpdateDescription?.groupChangeUpdate
if (groupChange != null) {
builder.updateMessage = ChatUpdateMessage(
groupChange = groupChange
)
} else if (record.body != null) {
try {
val decoded: ByteArray = decode(record.body)
val context = DecryptedGroupV2Context.ADAPTER.decode(decoded)
builder.updateMessage = ChatUpdateMessage(
groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account().getServiceIds(), context)
)
} catch (e: IOException) {
continue
}
} else {
continue
}
}
MessageTypes.isCallLog(record.type) -> {
val call = calls.getCallByMessageId(record.id)
if (call != null) {
@ -207,7 +234,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
Log.w(TAG, "Record missing a body, skipping")
continue
}
else -> builder.standardMessage = record.toTextMessage(reactionsById[id])
else -> builder.standardMessage = record.toTextMessage(reactionsById[id], mentions = mentionsById[id])
}
buffer += builder.build()
@ -261,12 +288,12 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?): StandardMessage {
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?): StandardMessage {
return StandardMessage(
quote = this.toQuote(),
text = Text(
body = this.body!!,
bodyRanges = this.bodyRanges?.toBackupBodyRanges() ?: emptyList()
bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList())
),
// TODO Link previews!
linkPreview = emptyList(),
@ -294,6 +321,16 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun List<Mention>.toBackupBodyRanges(): List<BackupBodyRange> {
return this.map {
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = SignalDatabase.recipients.getRecord(it.recipientId).aci?.toByteString()
)
}
}
private fun ByteArray.toBackupBodyRanges(): List<BackupBodyRange> {
val decoded: BodyRangeList = try {
BodyRangeList.ADAPTER.decode(this)
@ -306,7 +343,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = it.mentionUuid?.let { UuidUtil.parseOrThrow(it) }?.toByteArray()?.toByteString(),
mentionAci = it.mentionUuid?.let { uuid -> UuidUtil.parseOrThrow(uuid) }?.toByteArray()?.toByteString(),
style = it.style?.toBackupBodyRangeStyle()
)
}
@ -412,6 +449,17 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun ByteArray?.parseMessageExtras(): MessageExtras? {
if (this == null) {
return null
}
return try {
MessageExtras.ADAPTER.decode(this)
} catch (e: java.lang.Exception) {
null
}
}
private fun Cursor.toBackupMessageRecord(): BackupMessageRecord {
return BackupMessageRecord(
id = this.requireLong(MessageTable.ID),
@ -443,7 +491,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP),
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(),
baseType = this.requireLong(COLUMN_BASE_TYPE)
baseType = this.requireLong(COLUMN_BASE_TYPE),
messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras()
)
}
@ -477,6 +526,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val read: Boolean,
val networkFailureRecipientIds: Set<Long>,
val identityMismatchRecipientIds: Set<Long>,
val baseType: Long
val baseType: Long,
val messageExtras: MessageExtras?
)
}

View file

@ -28,11 +28,15 @@ import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.ReactionTable
import org.thoughtcrime.securesms.database.SQLiteDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
import org.thoughtcrime.securesms.database.documents.NetworkFailure
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
@ -40,6 +44,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
/**
@ -152,7 +157,6 @@ class ChatItemImportInserter(
if (buffer.size == 0) {
return false
}
buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach {
db.rawQuery("${it.query.where} RETURNING ${MessageTable.ID}", it.query.whereArgs).use { cursor ->
var index = 0
@ -177,6 +181,8 @@ class ChatItemImportInserter(
messageId = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME)
buffer.reset()
return true
}
@ -202,6 +208,27 @@ class ChatItemImportInserter(
}
}
}
if (this.standardMessage != null) {
val bodyRanges = this.standardMessage.text?.bodyRanges
if (!bodyRanges.isNullOrEmpty()) {
val mentions = bodyRanges.filter { it.mentionAci != null && it.start != null && it.length != null }
.mapNotNull {
val aci = ServiceId.ACI.parseOrNull(it.mentionAci!!)
if (aci != null && !aci.isUnknown) {
val id = RecipientId.from(aci)
Mention(id, it.start!!, it.length!!)
} else {
null
}
}
if (mentions.isNotEmpty()) {
followUp = { messageId ->
SignalDatabase.mentions.insert(threadId, messageId, mentions)
}
}
}
}
return MessageInsert(contentValues, followUp)
}
@ -243,6 +270,7 @@ class ChatItemImportInserter(
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
contentValues.put(MessageTable.NOTIFIED, 1)
}
contentValues.put(MessageTable.QUOTE_ID, 0)
@ -265,7 +293,6 @@ class ChatItemImportInserter(
val reactions: List<Reaction> = when {
this.standardMessage != null -> this.standardMessage.reactions
this.contactMessage != null -> this.contactMessage.reactions
this.voiceMessage != null -> this.voiceMessage.reactions
this.stickerMessage != null -> this.stickerMessage.reactions
else -> emptyList()
}
@ -342,7 +369,7 @@ class ChatItemImportInserter(
this.put(MessageTable.BODY, standardMessage.text.body)
if (standardMessage.text.bodyRanges.isNotEmpty()) {
this.put(MessageTable.MESSAGE_RANGES, standardMessage.text.bodyRanges.toLocalBodyRanges()?.encode() as ByteArray?)
this.put(MessageTable.MESSAGE_RANGES, standardMessage.text.bodyRanges.toLocalBodyRanges()?.encode())
}
}
@ -410,6 +437,17 @@ class ChatItemImportInserter(
// Calls don't use the incoming/outgoing flags, so we overwrite the flags here
this.put(MessageTable.TYPE, typeFlags)
}
updateMessage.groupChange != null -> {
put(MessageTable.BODY, "")
put(
MessageTable.MESSAGE_EXTRAS,
MessageExtras(
gv2UpdateDescription =
GV2UpdateDescription(groupChangeUpdate = updateMessage.groupChange)
).encode()
)
typeFlags = MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT
}
}
this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags)
}
@ -512,5 +550,11 @@ class ChatItemImportInserter(
) {
val size: Int
get() = listOf(messages.size, reactions.size, groupReceipts.size).max()
fun reset() {
messages.clear()
reactions.clear()
groupReceipts.clear()
}
}
}

View file

@ -28,38 +28,45 @@ import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDist
private val TAG = Log.tag(DistributionListTables::class.java)
data class DistributionRecipient(val id: RecipientId, val record: DistributionListRecord)
fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
val records = readableDatabase
.select()
.from(DistributionListTables.ListTable.TABLE_NAME)
.where(DistributionListTables.ListTable.IS_NOT_DELETED)
.run()
.readToList { cursor ->
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
DistributionListRecord(
id = id,
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES),
rawMembers = getRawMembers(id, privacyMode),
members = getMembers(id),
deletedAtTimestamp = 0L,
isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN),
privacyMode = privacyMode
val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID))
DistributionRecipient(
id = recipientId,
record = DistributionListRecord(
id = id,
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES),
rawMembers = getRawMembers(id, privacyMode),
members = getMembers(id),
deletedAtTimestamp = 0L,
isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN),
privacyMode = privacyMode
)
)
}
return records
.map { record ->
.map { recipient ->
BackupRecipient(
id = recipient.id.toLong(),
distributionList = BackupDistributionList(
name = record.name,
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
allowReplies = record.allowsReplies,
deletionTimestamp = record.deletedAtTimestamp,
privacyMode = record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = record.members.map { it.toLong() }
name = recipient.record.name,
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
allowReplies = recipient.record.allowsReplies,
deletionTimestamp = recipient.record.deletedAtTimestamp,
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = recipient.record.members.map { it.toLong() }
)
)
}

View file

@ -47,7 +47,8 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
MessageTable.READ,
MessageTable.NETWORK_FAILURES,
MessageTable.MISMATCHED_IDENTITIES,
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}"
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}",
MessageTable.MESSAGE_EXTRAS
)
.from(MessageTable.TABLE_NAME)
.where(

View file

@ -22,23 +22,31 @@ import org.signal.core.util.select
import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.Group
import org.thoughtcrime.securesms.backup.v2.proto.Self
import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.Closeable
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
@ -94,7 +102,8 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
"${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}",
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}"
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}",
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}"
)
.from(
"""
@ -102,6 +111,7 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
INNER JOIN ${GroupTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
"""
)
.where("${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY} IS NOT NULL")
.run()
return BackupGroupIterator(cursor)
@ -115,8 +125,10 @@ fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backup
// TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions
return when {
recipient.contact != null -> restoreContactFromBackup(recipient.contact)
recipient.group != null -> restoreGroupFromBackup(recipient.group)
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
recipient.self != null -> Recipient.self().id
recipient.releaseNotes != null -> restoreReleaseNotes()
else -> {
Log.w(TAG, "Unrecognized recipient type!")
null
@ -177,6 +189,7 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient
.values(
RecipientTable.BLOCKED to contact.blocked,
RecipientTable.HIDDEN to contact.hidden,
RecipientTable.TYPE to RecipientTable.RecipientType.INDIVIDUAL.id,
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
@ -193,6 +206,50 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient
return id
}
private fun RecipientTable.restoreReleaseNotes(): RecipientId {
val releaseChannelId: RecipientId = insertReleaseChannelRecipient()
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId)
setProfileName(releaseChannelId, ProfileName.asGiven("Signal"))
setMuted(releaseChannelId, Long.MAX_VALUE)
return releaseChannelId
}
private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
val masterKey = GroupMasterKey(group.masterKey.toByteArray())
val groupId = GroupId.v2(masterKey)
val placeholderState = DecryptedGroup.Builder()
.revision(GroupsV2StateProcessor.PLACEHOLDER_REVISION)
.build()
val values = ContentValues().apply {
put(RecipientTable.GROUP_ID, groupId.toString())
put(RecipientTable.AVATAR_COLOR, AvatarColorHash.forGroupId(groupId).serialize())
put(RecipientTable.PROFILE_SHARING, group.whitelisted)
put(RecipientTable.TYPE, RecipientTable.RecipientType.GV2.id)
put(RecipientTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
if (group.hideStory) {
val extras = RecipientExtras.Builder().hideStory(true).build()
put(RecipientTable.EXTRAS, extras.encode())
}
}
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
val groupValues = ContentValues().apply {
put(GroupTable.RECIPIENT_ID, recipientId)
put(GroupTable.GROUP_ID, groupId.toString())
put(GroupTable.TITLE, group.name)
put(GroupTable.V2_MASTER_KEY, masterKey.serialize())
put(GroupTable.V2_DECRYPTED_GROUP, placeholderState.encode())
put(GroupTable.V2_REVISION, placeholderState.revision)
put(GroupTable.SHOW_AS_STORY_STATE, group.storySendMode.toGroupShowAsStoryState().code)
}
writableDatabase.insert(GroupTable.TABLE_NAME, null, groupValues)
return RecipientId.from(recipientId)
}
private fun Contact.toLocalExtras(): RecipientExtras {
return RecipientExtras(
hideStory = this.hideStory
@ -235,8 +292,8 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long
return BackupRecipient(
id = id,
contact = Contact(
aci = aci?.toByteArray()?.toByteString(),
pni = pni?.toByteArray()?.toByteString(),
aci = aci?.rawUuid?.toByteArray()?.toByteString(),
pni = pni?.rawUuid?.toByteArray()?.toByteString(),
username = cursor.requireString(RecipientTable.USERNAME),
e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(),
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
@ -280,7 +337,8 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
hideStory = extras?.hideStory() ?: false,
storySendMode = showAsStoryState.toGroupStorySendMode()
storySendMode = showAsStoryState.toGroupStorySendMode(),
name = cursor.requireString(GroupTable.TITLE) ?: ""
)
)
}
@ -324,6 +382,14 @@ private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendM
}
}
private fun Group.StorySendMode.toGroupShowAsStoryState(): GroupTable.ShowAsStoryState {
return when (this) {
Group.StorySendMode.ENABLED -> GroupTable.ShowAsStoryState.ALWAYS
Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER
Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE
}
}
private val Contact.formattedE164: String?
get() {
return e164?.let {

View file

@ -6,15 +6,16 @@
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.recipients.RecipientId
import java.io.Closeable
@ -22,16 +23,21 @@ import java.io.Closeable
private val TAG = Log.tag(ThreadTable::class.java)
fun ThreadTable.getThreadsForBackup(): ChatIterator {
val cursor = readableDatabase
.select(
ThreadTable.ID,
ThreadTable.RECIPIENT_ID,
ThreadTable.ARCHIVED,
ThreadTable.PINNED,
ThreadTable.EXPIRES_IN
)
.from(ThreadTable.TABLE_NAME)
.run()
//language=sql
val query = """
SELECT
${ThreadTable.TABLE_NAME}.${ThreadTable.ID},
${ThreadTable.RECIPIENT_ID},
${ThreadTable.PINNED},
${ThreadTable.READ},
${ThreadTable.ARCHIVED},
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME},
${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL},
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING}
FROM ${ThreadTable.TABLE_NAME}
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
"""
val cursor = readableDatabase.query(query)
return ChatIterator(cursor)
}
@ -43,14 +49,29 @@ fun ThreadTable.clearAllDataForBackupRestore() {
}
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
return writableDatabase
val threadId = writableDatabase
.insertInto(ThreadTable.TABLE_NAME)
.values(
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
ThreadTable.PINNED to chat.pinnedOrder,
ThreadTable.ARCHIVED to chat.archived.toInt()
ThreadTable.ARCHIVED to chat.archived.toInt(),
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.FORCED_UNREAD.serialize() else ThreadTable.ReadStatus.READ.serialize(),
ThreadTable.ACTIVE to 1
)
.run()
writableDatabase
.update(
RecipientTable.TABLE_NAME,
contentValuesOf(
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
RecipientTable.MUTE_UNTIL to chat.muteUntilMs,
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs
),
"${RecipientTable.ID} = ?",
SqlUtil.buildArgs(recipientId.toLong())
)
return threadId
}
class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
@ -68,7 +89,10 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
expirationTimerMs = cursor.requireLong(ThreadTable.EXPIRES_IN)
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING)
)
}

View file

@ -28,6 +28,7 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.UuidUtil
import kotlin.jvm.optionals.getOrNull
object AccountDataProcessor {
@ -47,12 +48,11 @@ object AccountDataProcessor {
familyName = self.profileName.familyName,
avatarUrlPath = self.profileAvatar ?: "",
subscriptionManuallyCancelled = SignalStore.signalDonationsValues().isUserManuallyCancelled(),
username = SignalStore.account().username,
username = self.username.getOrNull(),
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
accountSettings = AccountData.AccountSettings(
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
noteToSelfMarkedUnread = record != null && record.syncExtras.isForcedUnread,
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
@ -61,13 +61,14 @@ object AccountDataProcessor {
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
universalExpireTimer = SignalStore.settings().universalExpireTimer,
preferredReactionEmoji = SignalStore.emojiValues().reactions,
preferredReactionEmoji = SignalStore.emojiValues().rawReactions,
storiesDisabled = SignalStore.storyValues().isFeatureDisabled,
hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory,
hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories,
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
displayBadgesOnProfile = SignalStore.signalDonationsValues().getDisplayBadgesOnProfile(),
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = SignalStore.uiHints().hasCompletedUsernameOnboarding()
)
)
)
@ -122,6 +123,14 @@ object AccountDataProcessor {
)
SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
}
if (settings.preferredReactionEmoji.isNotEmpty()) {
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
}
if (settings.hasCompletedUsernameOnboarding) {
SignalStore.uiHints().setHasCompletedUsernameOnboarding(true)
}
}
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.Chat
@ -18,10 +19,15 @@ import org.thoughtcrime.securesms.recipients.RecipientId
object ChatBackupProcessor {
val TAG = Log.tag(ChatBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.threads.getThreadsForBackup().use { reader ->
for (chat in reader) {
emitter.emit(Frame(chat = chat))
if (exportState.recipientIds.contains(chat.recipientId)) {
exportState.threadIds.add(chat.id)
emitter.emit(Frame(chat = chat))
} else {
Log.w(TAG, "dropping thread for deleted recipient ${chat.recipientId}")
}
}
}
}

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter
import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup
@ -17,10 +18,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase
object ChatItemBackupProcessor {
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.messages.getMessagesForBackup().use { chatItems ->
for (chatItem in chatItems) {
emitter.emit(Frame(chatItem = chatItem))
if (exportState.threadIds.contains(chatItem.chatId)) {
emitter.emit(Frame(chatItem = chatItem))
}
}
}
}

View file

@ -7,13 +7,17 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
@ -22,12 +26,24 @@ object RecipientBackupProcessor {
val TAG = Log.tag(RecipientBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
fun export(state: ExportState, emitter: BackupFrameEmitter) {
val selfId = Recipient.self().id.toLong()
val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId
if (releaseChannelId != null) {
emitter.emit(
Frame(
recipient = BackupRecipient(
id = releaseChannelId.toLong(),
releaseNotes = ReleaseNotes()
)
)
)
}
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
for (backupRecipient in reader) {
if (backupRecipient != null) {
state.recipientIds.add(backupRecipient.id)
emitter.emit(Frame(recipient = backupRecipient))
}
}
@ -35,11 +51,13 @@ object RecipientBackupProcessor {
SignalDatabase.recipients.getGroupsForBackup().use { reader ->
for (backupRecipient in reader) {
state.recipientIds.add(backupRecipient.id)
emitter.emit(Frame(recipient = backupRecipient))
}
}
SignalDatabase.distributionLists.getAllForBackup().forEach {
state.recipientIds.add(it.id)
emitter.emit(Frame(recipient = it))
}
}

View file

@ -5,8 +5,10 @@
package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupExportWriter : AutoCloseable {
fun write(header: BackupInfo)
fun write(frame: Frame)
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupImportReader : Iterator<Frame>, AutoCloseable {
fun getHeader(): BackupInfo?
}

View file

@ -10,6 +10,7 @@ import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.signal.core.util.stream.MacInputStream
import org.signal.core.util.stream.TruncatingInputStream
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@ -33,8 +34,9 @@ class EncryptedBackupReader(
aci: ACI,
streamLength: Long,
dataStream: () -> InputStream
) : Iterator<Frame>, AutoCloseable {
) : BackupImportReader {
val backupInfo: BackupInfo?
var next: Frame? = null
val stream: InputStream
@ -56,10 +58,14 @@ class EncryptedBackupReader(
cipher
)
)
backupInfo = readHeader()
next = read()
}
override fun getHeader(): BackupInfo? {
return backupInfo
}
override fun hasNext(): Boolean {
return next != null
}
@ -71,6 +77,17 @@ class EncryptedBackupReader(
} ?: throw NoSuchElementException()
}
private fun readHeader(): BackupInfo? {
try {
val length = stream.readVarInt32().takeIf { it >= 0 } ?: return null
val headerBytes: ByteArray = stream.readNBytesOrThrow(length)
return BackupInfo.ADAPTER.decode(headerBytes)
} catch (e: EOFException) {
return null
}
}
private fun read(): Frame? {
try {
val length = stream.readVarInt32().also { if (it < 0) return null }

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.stream.MacOutputStream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@ -56,6 +57,13 @@ class EncryptedBackupWriter(
)
}
override fun write(header: BackupInfo) {
val headerBytes = header.encode()
mainStream.writeVarInt32(headerBytes.size)
mainStream.write(headerBytes)
}
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.EOFException
import java.io.InputStream
@ -14,14 +15,20 @@ import java.io.InputStream
/**
* Reads a plaintext backup import stream one frame at a time.
*/
class PlainTextBackupReader(val inputStream: InputStream) : Iterator<Frame> {
class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader {
val backupInfo: BackupInfo?
var next: Frame? = null
init {
backupInfo = readHeader()
next = read()
}
override fun getHeader(): BackupInfo? {
return backupInfo
}
override fun hasNext(): Boolean {
return next != null
}
@ -33,6 +40,21 @@ class PlainTextBackupReader(val inputStream: InputStream) : Iterator<Frame> {
} ?: throw NoSuchElementException()
}
override fun close() {
inputStream.close()
}
private fun readHeader(): BackupInfo? {
try {
val length = inputStream.readVarInt32().takeIf { it >= 0 } ?: return null
val headerBytes: ByteArray = inputStream.readNBytesOrThrow(length)
return BackupInfo.ADAPTER.decode(headerBytes)
} catch (e: EOFException) {
return null
}
}
private fun read(): Frame? {
try {
val length = inputStream.readVarInt32().also { if (it < 0) return null }

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.IOException
import java.io.OutputStream
@ -15,6 +16,14 @@ import java.io.OutputStream
*/
class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter {
@Throws(IOException::class)
override fun write(header: BackupInfo) {
val headerBytes: ByteArray = header.encode()
outputStream.writeVarInt32(headerBytes.size)
outputStream.write(headerBytes)
}
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()

View file

@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.visible
class BadgeImageView @JvmOverloads constructor(
context: Context,
@ -71,6 +72,10 @@ class BadgeImageView @JvmOverloads constructor(
}
}
fun isShowingBadge(): Boolean {
return drawable != null
}
private fun clearDrawable() {
if (drawable != null) {
setImageDrawable(null)

View file

@ -48,18 +48,25 @@ class UpdateCallLinkRepository(
.subscribeOn(Schedulers.io())
}
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
fun deleteCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRevoked(credentials, true)
.deleteCallLink(credentials)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
return { result ->
if (result is UpdateCallLinkResult.Success) {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
ApplicationDependencies.getJobManager().add(CallLinkUpdateSendJob(credentials.roomId))
when (result) {
is UpdateCallLinkResult.Update -> {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
ApplicationDependencies.getJobManager().add(CallLinkUpdateSendJob(credentials.roomId))
}
is UpdateCallLinkResult.Delete -> {
SignalDatabase.callLinks.markRevoked(credentials.roomId)
ApplicationDependencies.getJobManager().add(CallLinkUpdateSendJob(credentials.roomId))
}
else -> {}
}
}
}

View file

@ -159,7 +159,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
private fun setCallName(callName: String) {
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to update call link name")
toastFailure()
}
@ -168,7 +168,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
private fun setApproveAllMembers(approveAllMembers: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
@ -177,7 +177,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
private fun toggleApproveAllMembers() {
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}

View file

@ -49,7 +49,9 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import java.time.Instant
/**
@ -119,15 +121,29 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
}
}
override fun onCopyClicked() {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
override fun onShareLinkViaSignalClicked() {
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot))
)
)
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
lifecycleDisposable += viewModel.delete().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
is UpdateCallLinkResult.Update -> ActivityCompat.finishAfterTransition(requireActivity())
else -> {
Log.w(TAG, "Failed to revoke. $it")
toastFailure()
@ -142,7 +158,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
override fun onApproveAllMembersChanged(checked: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
@ -151,7 +167,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
@ -175,6 +191,8 @@ private interface CallLinkDetailsCallback {
fun onJoinClicked()
fun onEditNameClicked()
fun onShareClicked()
fun onCopyClicked()
fun onShareLinkViaSignalClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
@ -216,6 +234,8 @@ private fun CallLinkDetailsPreview() {
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
override fun onShareClicked() = Unit
override fun onCopyClicked() = Unit
override fun onShareLinkViaSignalClicked() = Unit
override fun onDeleteClicked() = Unit
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
}
@ -265,6 +285,18 @@ private fun CallLinkDetails(
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),

View file

@ -71,9 +71,9 @@ class CallLinkDetailsViewModel(
return mutationRepository.setCallName(credentials, name)
}
fun revoke(): Single<UpdateCallLinkResult> {
fun delete(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
return mutationRepository.deleteCallLink(credentials)
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {

View file

@ -43,7 +43,8 @@ class CallLogRepository(
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.messages.markAllCallEventsRead()
SignalDatabase.calls.markAllCallEventsRead()
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsRead(System.currentTimeMillis()))
}
}
@ -101,7 +102,7 @@ class CallLogRepository(
}
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
}.flatMap(this::revokeAndCollectResults).map { 0 }.subscribeOn(Schedulers.io())
}.flatMap(this::deleteAndCollectResults).map { 0 }.subscribeOn(Schedulers.io())
}
/**
@ -117,7 +118,7 @@ class CallLogRepository(
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteNonAdminCallLinks(allCallLinkIds)
SignalDatabase.callLinks.getAdminCallLinks(allCallLinkIds)
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
}.flatMap(this::deleteAndCollectResults).subscribeOn(Schedulers.io())
}
/**
@ -133,16 +134,16 @@ class CallLogRepository(
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteAllNonAdminCallLinksExcept(allCallLinkIds)
SignalDatabase.callLinks.getAllAdminCallLinksExcept(allCallLinkIds)
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
}.flatMap(this::deleteAndCollectResults).subscribeOn(Schedulers.io())
}
private fun revokeAndCollectResults(callLinksToRevoke: Set<CallLinkTable.CallLink>): Single<Int> {
private fun deleteAndCollectResults(callLinksToRevoke: Set<CallLinkTable.CallLink>): Single<Int> {
return Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
updateCallLinkRepository.deleteCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
acc + (if (current is UpdateCallLinkResult.Update) 0 else 1)
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {

View file

@ -16,16 +16,14 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery.refresh
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import java.io.IOException
import java.util.Optional
import java.util.function.Consumer
import kotlin.time.Duration.Companion.seconds
class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment.NewCallCallback {
@ -46,38 +44,36 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.")
if (SignalStore.account().isRegistered) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.")
val progress = SimpleProgressDialog.show(this)
SimpleTask.run<Recipient>(lifecycle, {
var resolved = Recipient.external(this, number!!)
if (!resolved.isRegistered || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.")
resolved = try {
refresh(this, resolved, false, 10.seconds.inWholeMilliseconds)
Recipient.resolved(resolved.id)
} catch (e: IOException) {
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.")
return@run null
}
}
resolved
}) { resolved: Recipient? ->
SimpleTask.run(lifecycle, { RecipientRepository.lookupNewE164(this, number!!) }, { result ->
progress.dismiss()
if (resolved != null) {
if (resolved.isRegistered && resolved.hasServiceId()) {
launch(resolved)
} else {
when (result) {
is RecipientRepository.LookupResult.Success -> {
val resolved = Recipient.resolved(result.recipientId)
if (resolved.isRegistered && resolved.hasServiceId()) {
launch(resolved)
}
}
is RecipientRepository.LookupResult.NotFound,
is RecipientRepository.LookupResult.InvalidEntry -> {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, resolved.getDisplayName(this)))
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number))
.setPositiveButton(android.R.string.ok, null)
.show()
}
else -> {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show()
}
} else {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
})
}
}
callback.accept(true)

View file

@ -9,7 +9,6 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -20,8 +19,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
public class FromTextView extends SimpleEmojiTextView {
private static final String TAG = Log.tag(FromTextView.class);
public FromTextView(Context context) {
super(context);
}
@ -31,22 +28,18 @@ public class FromTextView extends SimpleEmojiTextView {
}
public void setText(Recipient recipient) {
setText(recipient, true);
setText(recipient, null);
}
public void setText(Recipient recipient, boolean read) {
setText(recipient, read, null);
public void setText(Recipient recipient, @Nullable CharSequence suffix) {
setText(recipient, recipient.getDisplayName(getContext()), suffix);
}
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
setText(recipient, recipient.getDisplayNameOrUsername(getContext()), read, suffix);
public void setText(Recipient recipient, @Nullable CharSequence fromString, @Nullable CharSequence suffix) {
setText(recipient, fromString, suffix, true);
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
setText(recipient, fromString, read, suffix, true);
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix, boolean asThread) {
public void setText(Recipient recipient, @Nullable CharSequence fromString, @Nullable CharSequence suffix, boolean asThread) {
SpannableStringBuilder builder = new SpannableStringBuilder();
if (asThread && recipient.isSelf()) {

View file

@ -351,6 +351,13 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
if (isStoryReply() && originalMissing) {
thumbnailView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
return;
}
// TODO [alex] -- do we need this? mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
thumbnailView.setPadding(0, 0, 0, 0);

View file

@ -279,6 +279,7 @@ class ChangeNumberRepository(
)
)
pniMetadataStore.isSignedPreKeyRegistered = true
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId
pniProtocolStore.identities().saveIdentityWithoutSideEffects(
Recipient.self().id,

View file

@ -12,7 +12,11 @@ import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -20,12 +24,26 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@ -34,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Snackbars
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.bytes
import org.signal.core.util.getLength
@ -48,6 +67,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
private val viewModel: InternalBackupPlaygroundViewModel by viewModels()
private lateinit var exportFileLauncher: ActivityResultLauncher<Intent>
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
private lateinit var validateFileLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -72,43 +92,110 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
validateFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
requireContext().contentResolver.getLength(uri)?.let { length ->
viewModel.validate(length) { requireContext().contentResolver.openInputStream(uri)!! }
}
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
val mediaState by viewModel.mediaState
Screen(
state = state,
onExportClicked = { viewModel.export() },
onImportMemoryClicked = { viewModel.import() },
onImportFileClicked = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
LaunchedEffect(Unit) {
viewModel.loadMedia()
}
importFileLauncher.launch(intent)
Tabs(
mainContent = {
Screen(
state = state,
onExportClicked = { viewModel.export() },
onImportMemoryClicked = { viewModel.import() },
onImportFileClicked = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
importFileLauncher.launch(intent)
},
onPlaintextClicked = { viewModel.onPlaintextToggled() },
onSaveToDiskClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
}
exportFileLauncher.launch(intent)
},
onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() },
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() },
onValidateFileClicked = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
validateFileLauncher.launch(intent)
}
)
},
onPlaintextClicked = { viewModel.onPlaintextToggled() },
onSaveToDiskClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
}
exportFileLauncher.launch(intent)
},
onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() },
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }
mediaContent = { snackbarHostState ->
MediaList(
state = mediaState,
snackbarHostState = snackbarHostState,
backupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
deleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) },
batchBackupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
batchDeleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) }
)
}
)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@Composable
fun Tabs(
mainContent: @Composable () -> Unit,
mediaContent: @Composable (snackbarHostState: SnackbarHostState) -> Unit
) {
val tabs = listOf("Main", "Media")
var tabIndex by remember { mutableIntStateOf(0) }
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { Snackbars.Host(snackbarHostState) },
topBar = {
TabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, tab ->
Tab(
text = { Text(tab) },
selected = index == tabIndex,
onClick = { tabIndex = index }
)
}
}
}
) {
Surface(modifier = Modifier.padding(it)) {
when (tabIndex) {
0 -> mainContent()
1 -> mediaContent(snackbarHostState)
}
}
}
}
@ -120,6 +207,7 @@ fun Screen(
onImportFileClicked: () -> Unit = {},
onPlaintextClicked: () -> Unit = {},
onSaveToDiskClicked: () -> Unit = {},
onValidateFileClicked: () -> Unit = {},
onUploadToRemoteClicked: () -> Unit = {},
onCheckRemoteBackupStateClicked: () -> Unit = {}
) {
@ -165,15 +253,23 @@ fun Screen(
Text("Import from file")
}
Buttons.LargeTonal(
onClick = onValidateFileClicked
) {
Text("Validate file")
}
Spacer(modifier = Modifier.height(16.dp))
when (state.backupState) {
BackupState.NONE -> {
StateLabel("")
}
BackupState.EXPORT_IN_PROGRESS -> {
StateLabel("Export in progress...")
}
BackupState.EXPORT_DONE -> {
StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.")
@ -183,6 +279,7 @@ fun Screen(
Text("Save to file")
}
}
BackupState.IMPORT_IN_PROGRESS -> {
StateLabel("Import in progress...")
}
@ -202,12 +299,15 @@ fun Screen(
is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> {
StateLabel("Exists/allocated. ${state.remoteBackupState.response.mediaCount} media items, using ${state.remoteBackupState.response.usedSpace} bytes (${state.remoteBackupState.response.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)")
}
InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> {
StateLabel("Hit an unknown error. Check the logs.")
}
InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> {
StateLabel("Not found.")
}
InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> {
StateLabel("Hit the button above to check the state.")
}
@ -228,12 +328,15 @@ fun Screen(
BackupUploadState.NONE -> {
StateLabel("")
}
BackupUploadState.UPLOAD_IN_PROGRESS -> {
StateLabel("Upload in progress...")
}
BackupUploadState.UPLOAD_DONE -> {
StateLabel("Upload complete.")
}
BackupUploadState.UPLOAD_FAILED -> {
StateLabel("Upload failed.")
}
@ -251,6 +354,124 @@ private fun StateLabel(text: String) {
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MediaList(
state: InternalBackupPlaygroundViewModel.MediaState,
snackbarHostState: SnackbarHostState,
backupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
deleteBackupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
batchBackupAttachmentMedia: (Set<String>) -> Unit,
batchDeleteBackupAttachmentMedia: (Set<String>) -> Unit
) {
LaunchedEffect(state.error?.id) {
state.error?.let {
snackbarHostState.showSnackbar(it.errorText)
}
}
var selectionState by remember { mutableStateOf(MediaMultiSelectState()) }
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
count = state.attachments.size,
key = { index -> state.attachments[index].id }
) { index ->
val attachment = state.attachments[index]
Row(
modifier = Modifier
.combinedClickable(
onClick = {
if (selectionState.selecting) {
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.mediaId)) selectionState.selected - attachment.mediaId else selectionState.selected + attachment.mediaId)
}
},
onLongClick = {
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.mediaId))
}
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
if (selectionState.selecting) {
Checkbox(
checked = selectionState.selected.contains(attachment.mediaId),
onCheckedChange = { selected ->
selectionState = selectionState.copy(selected = if (selected) selectionState.selected + attachment.mediaId else selectionState.selected - attachment.mediaId)
}
)
}
Column(modifier = Modifier.weight(1f, true)) {
Text(text = "Attachment ${attachment.title}")
Text(text = "State: ${attachment.state}")
}
if (attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.INIT ||
attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.IN_PROGRESS
) {
CircularProgressIndicator()
} else {
Button(
enabled = !selectionState.selecting,
onClick = {
when (attachment.state) {
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> backupAttachmentMedia(attachment)
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> deleteBackupAttachmentMedia(attachment)
else -> throw AssertionError("Unsupported state: ${attachment.state}")
}
}
) {
Text(
text = when (attachment.state) {
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> "Backup"
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> "Remote Delete"
else -> throw AssertionError("Unsupported state: ${attachment.state}")
}
)
}
}
}
}
}
if (selectionState.selecting) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
.background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp)
)
.padding(8.dp)
) {
Button(onClick = { selectionState = MediaMultiSelectState() }) {
Text("Cancel")
}
Button(onClick = {
batchBackupAttachmentMedia(selectionState.selected)
selectionState = MediaMultiSelectState()
}) {
Text("Backup")
}
Button(onClick = {
batchDeleteBackupAttachmentMedia(selectionState.selected)
selectionState = MediaMultiSelectState()
}) {
Text("Delete")
}
}
}
}
}
private data class MediaMultiSelectState(
val selecting: Boolean = false,
val selected: Set<String> = emptySet()
)
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -13,17 +13,27 @@ 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.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupMetadata
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.BackupKey
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.UUID
import kotlin.random.Random
class InternalBackupPlaygroundViewModel : ViewModel() {
private val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
var backupData: ByteArray? = null
val disposables = CompositeDisposable()
@ -31,6 +41,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false))
val state: State<ScreenState> = _state
private val _mediaState: MutableState<MediaState> = mutableStateOf(MediaState())
val mediaState: State<MediaState> = _mediaState
fun export() {
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
@ -78,6 +91,19 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun validate(length: Long, inputStreamFactory: () -> InputStream) {
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.validate(length, inputStreamFactory, selfData) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nothing ->
backupData = null
_state.value = _state.value.copy(backupState = BackupState.NONE)
}
}
fun onPlaintextToggled() {
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
}
@ -104,9 +130,11 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
result is NetworkResult.Success -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result))
}
result is NetworkResult.StatusCodeError && result.code == 404 -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound)
}
else -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError)
}
@ -114,6 +142,98 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun loadMedia() {
disposables += Single
.fromCallable { SignalDatabase.attachments.debugGetLatestAttachments() }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.single())
.subscribeBy {
_mediaState.set { update(attachments = it.map { a -> BackupAttachment.from(backupKey, a) }) }
}
disposables += Single
.fromCallable { BackupRepository.debugGetArchivedMediaState() }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.single())
.subscribeBy { result ->
when (result) {
is NetworkResult.Success -> _mediaState.set { update(archiveStateLoaded = true, backedUpMediaIds = result.result.map { it.mediaId }.toSet()) }
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
}
}
}
fun backupAttachmentMedia(mediaIds: Set<String>) {
disposables += Single.fromCallable { mediaIds.mapNotNull { mediaState.value.idToAttachment[it]?.dbAttachment }.toList() }
.map { BackupRepository.archiveMedia(it) }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.single())
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + mediaIds) } }
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - mediaIds) } }
.subscribeBy { result ->
when (result) {
is NetworkResult.Success -> {
val response = result.result
val successes = response.responses.filter { it.status == 200 }
val failures = response.responses - successes.toSet()
_mediaState.set {
var updated = update(backedUpMediaIds = backedUpMediaIds + successes.map { it.mediaId })
if (failures.isNotEmpty()) {
updated = updated.copy(error = MediaStateError(errorText = failures.toString()))
}
updated
}
}
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
}
}
}
fun backupAttachmentMedia(attachment: BackupAttachment) {
disposables += Single.fromCallable { BackupRepository.archiveMedia(attachment.dbAttachment) }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.single())
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + attachment.mediaId) } }
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - attachment.mediaId) } }
.subscribeBy {
when (it) {
is NetworkResult.Success -> {
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds + attachment.mediaId) }
}
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
}
}
}
fun deleteBackupAttachmentMedia(mediaIds: Set<String>) {
deleteBackupAttachmentMedia(mediaIds.mapNotNull { mediaState.value.idToAttachment[it] }.toList())
}
fun deleteBackupAttachmentMedia(attachment: BackupAttachment) {
deleteBackupAttachmentMedia(listOf(attachment))
}
private fun deleteBackupAttachmentMedia(attachments: List<BackupAttachment>) {
val ids = attachments.map { it.mediaId }.toSet()
disposables += Single.fromCallable { BackupRepository.deleteArchivedMedia(attachments.map { it.dbAttachment }) }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.single())
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + ids) } }
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - ids) } }
.subscribeBy {
when (it) {
is NetworkResult.Success -> {
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds - ids) }
}
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
}
}
}
override fun onCleared() {
disposables.clear()
}
@ -139,4 +259,77 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
object GeneralError : RemoteBackupState()
data class Available(val response: BackupMetadata) : RemoteBackupState()
}
data class MediaState(
val backupStateLoaded: Boolean = false,
val attachments: List<BackupAttachment> = emptyList(),
val backedUpMediaIds: Set<String> = emptySet(),
val inProgressMediaIds: Set<String> = emptySet(),
val error: MediaStateError? = null
) {
val idToAttachment: Map<String, BackupAttachment> = attachments.associateBy { it.mediaId }
fun update(
archiveStateLoaded: Boolean = this.backupStateLoaded,
attachments: List<BackupAttachment> = this.attachments,
backedUpMediaIds: Set<String> = this.backedUpMediaIds,
inProgressMediaIds: Set<String> = this.inProgressMediaIds
): MediaState {
val updatedAttachments = if (archiveStateLoaded) {
attachments.map {
val state = if (inProgressMediaIds.contains(it.mediaId)) {
BackupAttachment.State.IN_PROGRESS
} else if (backedUpMediaIds.contains(it.mediaId)) {
BackupAttachment.State.UPLOADED
} else {
BackupAttachment.State.LOCAL_ONLY
}
it.copy(state = state)
}
} else {
attachments
}
return copy(
backupStateLoaded = archiveStateLoaded,
attachments = updatedAttachments,
backedUpMediaIds = backedUpMediaIds
)
}
}
data class BackupAttachment(
val dbAttachment: DatabaseAttachment,
val state: State = State.INIT,
val mediaId: String = Base64.encodeUrlSafeWithPadding(Random.nextBytes(15))
) {
val id: Any = dbAttachment.attachmentId
val title: String = dbAttachment.attachmentId.toString()
enum class State {
INIT,
LOCAL_ONLY,
UPLOADED,
IN_PROGRESS
}
companion object {
fun from(backupKey: BackupKey, dbAttachment: DatabaseAttachment): BackupAttachment {
return BackupAttachment(
dbAttachment = dbAttachment,
mediaId = backupKey.deriveMediaId(Base64.decode(dbAttachment.dataHash!!)).toString()
)
}
}
}
data class MediaStateError(
val id: UUID = UUID.randomUUID(),
val errorText: String
)
fun <T> MutableState<T>.set(update: T.() -> T) {
this.value = this.value.update()
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
@ -65,7 +66,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
onEveryoneCanFindMeByNumberClicked = viewModel::setEveryoneCanFindMeByMyNumber,
onNobodyCanFindMeByNumberClicked = {
if (!state.phoneNumberSharing) {
viewModel.setNobodyCanFindMeByMyNumber()
onNobodyCanFindMeByNumberClicked()
} else {
lifecycleScope.launch {
snackbarHostState.showSnackbar(
@ -77,6 +78,15 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
}
)
}
private fun onNobodyCanFindMeByNumberClicked() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_title)
.setMessage(getString(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_message))
.setNegativeButton(getString(R.string.PhoneNumberPrivacySettingsFragment__cancel), null)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.setNobodyCanFindMeByMyNumber() }
.show()
}
}
@Composable

View file

@ -13,4 +13,6 @@ sealed class QrScanResult {
object InvalidData : QrScanResult()
object NetworkError : QrScanResult()
object QrNotFound : QrScanResult()
}

View file

@ -8,6 +8,8 @@ import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
@ -40,11 +42,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
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.core.app.ShareCompat
import androidx.core.app.TaskStackBuilder
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@ -52,9 +56,11 @@ import androidx.lifecycle.LifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
@ -63,13 +69,16 @@ import org.signal.core.ui.Dialogs
import org.signal.core.ui.Snackbars
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.CommunicationActions
import java.io.ByteArrayOutputStream
import java.util.UUID
@ -79,6 +88,18 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val disposables: LifecycleDisposable = LifecycleDisposable()
private lateinit var galleryLauncher: ActivityResultLauncher<Unit>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
galleryLauncher = registerForActivityResult(UsernameQrImageSelectionActivity.Contract()) { uri ->
if (uri != null) {
viewModel.scanImage(requireContext(), uri)
}
}
}
override fun onStart() {
super.onStart()
setFragmentResultListener(UsernameLinkShareBottomSheet.REQUEST_KEY) { key, bundle ->
@ -99,6 +120,14 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
viewModel.onTabSelected(ActiveTab.Scan)
}
val galleryPermissionState: MultiplePermissionsState = rememberMultiplePermissionsState(permissions = PermissionCompat.forImages().toList()) { grants ->
if (grants.values.all { it }) {
galleryLauncher.launch(Unit)
} else {
Toast.makeText(requireContext(), R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT).show()
}
}
MainScreen(
state = state,
navController = navController,
@ -111,6 +140,13 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) },
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
onQrResultHandled = { viewModel.onQrResultHandled() },
onOpenGalleryClicked = {
if (galleryPermissionState.allPermissionsGranted) {
galleryLauncher.launch(Unit)
} else {
galleryPermissionState.launchMultiplePermissionRequest()
}
},
onLinkReset = { viewModel.onUsernameLinkReset() },
onBackNavigationPressed = { requireActivity().onBackPressed() },
linkCopiedEvent = linkCopiedEvent
@ -127,6 +163,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun MainScreen(
state: UsernameLinkSettingsState,
@ -140,10 +177,13 @@ private fun MainScreen(
onShareBadge: () -> Unit = {},
onQrCodeScanned: (String) -> Unit = {},
onQrResultHandled: () -> Unit = {},
onOpenGalleryClicked: () -> Unit = {},
onLinkReset: () -> Unit = {},
onBackNavigationPressed: () -> Unit = {},
linkCopiedEvent: UUID? = null
) {
val context = LocalContext.current
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
val scope: CoroutineScope = rememberCoroutineScope()
var showResetDialog: Boolean by remember { mutableStateOf(false) }
@ -204,7 +244,15 @@ private fun MainScreen(
qrScanResult = state.qrScanResult,
onQrCodeScanned = onQrCodeScanned,
onQrResultHandled = onQrResultHandled,
modifier = Modifier.padding(contentPadding)
onOpenGalleryClicked = onOpenGalleryClicked,
modifier = Modifier.padding(contentPadding),
onRecipientFound = { recipient ->
val taskStack = TaskStackBuilder
.create(context)
.addNextIntent(MainActivity.clearTop(context))
CommunicationActions.startConversation(context, recipient, null, taskStack)
}
)
}
}

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
@ -9,6 +10,7 @@ import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
@ -29,6 +31,7 @@ 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.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.logging.Log
@ -41,7 +44,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NetworkUtil
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
@ -164,16 +166,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
indeterminateProgress = true
)
disposable += UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
}
}
.subscribeOn(Schedulers.io())
disposable += UsernameQrScanRepository.lookupUsernameUrl(url)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
_state.value = _state.value.copy(
@ -193,6 +186,21 @@ class UsernameLinkSettingsViewModel : ViewModel() {
_linkCopiedEvent.value = UUID.randomUUID()
}
fun scanImage(context: Context, uri: Uri) {
_state.value = _state.value.copy(
indeterminateProgress = true
)
disposable += UsernameQrScanRepository.scanImageUriForQrCode(context, uri)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { result ->
_state.value = _state.value.copy(
qrScanResult = result,
indeterminateProgress = false
)
}
}
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
url.map { QrCodeData.forData(it, 64) }

View file

@ -0,0 +1,64 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment
/**
* Select username qr code from gallery instead of using camera.
*/
class UsernameQrImageSelectionActivity : AppCompatActivity(), MediaGalleryFragment.Callbacks {
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN)
setContentView(R.layout.username_qr_image_selection_activity)
}
@SuppressLint("LogTagInlined")
override fun onMediaSelected(media: Media) {
setResult(RESULT_OK, Intent().setData(media.uri))
finish()
}
override fun onToolbarNavigationClicked() {
setResult(RESULT_CANCELED)
finish()
}
override fun isCameraEnabled() = false
override fun isMultiselectEnabled() = false
class Contract : ActivityResultContract<Unit, Uri?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, UsernameQrImageSelectionActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return if (resultCode == RESULT_OK) {
intent?.data
} else {
null
}
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Context
import android.net.Uri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.toOptional
import org.signal.qr.QrProcessor
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
/**
* A collection of functions to help with scanning QR codes for usernames.
*/
object UsernameQrScanRepository {
/**
* Given a URL, will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s.
*/
fun lookupUsernameUrl(url: String): Single<QrScanResult> {
return UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
}
}
.subscribeOn(Schedulers.io())
}
/**
* Given a URI pointing to an image that may contain a username QR code, this will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s.
*/
fun scanImageUriForQrCode(context: Context, uri: Uri): Single<QrScanResult> {
val loadBitmap = Glide.with(context)
.asBitmap()
.format(DecodeFormat.PREFER_ARGB_8888)
.load(uri)
.submit()
return Single.fromFuture(loadBitmap)
.map { QrProcessor().getScannedData(it).toOptional() }
.flatMap {
if (it.isPresent) {
lookupUsernameUrl(it.get())
} else {
Single.just(QrScanResult.QrNotFound)
}
}
.subscribeOn(Schedulers.io())
}
}

View file

@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -15,11 +19,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@ -27,10 +32,11 @@ import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.ui.Dialogs
import org.signal.core.ui.theme.SignalTheme
import org.signal.qr.QrScannerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
/**
@ -43,30 +49,39 @@ fun UsernameQrScanScreen(
qrScanResult: QrScanResult?,
onQrCodeScanned: (String) -> Unit,
onQrResultHandled: () -> Unit,
onOpenGalleryClicked: () -> Unit,
onRecipientFound: (Recipient) -> Unit,
modifier: Modifier = Modifier
) {
val path = remember { Path() }
when (qrScanResult) {
QrScanResult.InvalidData -> {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled)
QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled)
}
QrScanResult.NetworkError -> {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
}
QrScanResult.QrNotFound -> {
QrScanResultDialog(
title = stringResource(R.string.UsernameLinkSettings_qr_code_not_found),
message = stringResource(R.string.UsernameLinkSettings_try_scanning_another_image_containing_a_signal_qr_code),
onDismiss = onQrResultHandled
)
}
is QrScanResult.NotFound -> {
if (qrScanResult.username != null) {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
} else {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled)
QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled)
}
}
is QrScanResult.Success -> {
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
onQrResultHandled()
onRecipientFound(qrScanResult.recipient)
}
null -> {}
@ -77,25 +92,46 @@ fun UsernameQrScanScreen(
.fillMaxWidth()
.fillMaxHeight()
) {
AndroidView(
factory = { context ->
val view = QrScannerView(context)
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
onQrCodeScanned(data)
}
view
},
update = { view ->
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
},
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, true)
.drawWithContent {
drawContent()
drawQrCrosshair(path)
}
)
) {
AndroidView(
factory = { context ->
val view = QrScannerView(context)
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
onQrCodeScanned(data)
}
view
},
update = { view ->
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
},
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.drawWithContent {
drawContent()
drawQrCrosshair(path)
}
)
FloatingActionButton(
shape = CircleShape,
containerColor = SignalTheme.colors.colorSurface1,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
onClick = onOpenGalleryClicked
) {
Image(
painter = painterResource(id = R.drawable.symbol_album_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface)
)
}
}
Row(
modifier = Modifier
@ -114,8 +150,9 @@ fun UsernameQrScanScreen(
}
@Composable
private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) {
private fun QrScanResultDialog(title: String? = null, message: String, onDismiss: () -> Unit) {
Dialogs.SimpleMessageDialog(
title = title,
message = message,
dismiss = stringResource(id = android.R.string.ok),
onDismiss = onDismiss

View file

@ -0,0 +1,167 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.LifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.signal.core.ui.Dialogs
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* Prompts the user to scan a username QR code. Uses the activity result to communicate the recipient that was found, or null if no valid usernames were scanned.
* See [Contract].
*/
class UsernameQrScannerActivity : AppCompatActivity() {
companion object {
private const val KEY_RECIPIENT_ID = "recipient_id"
}
private val viewModel: UsernameQrScannerViewModel by viewModels()
private val disposables = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
disposables.bindTo(this)
val galleryLauncher = registerForActivityResult(UsernameQrImageSelectionActivity.Contract()) { uri ->
if (uri != null) {
viewModel.onQrImageSelected(this, uri)
}
}
setContent {
val galleryPermissionState: MultiplePermissionsState = rememberMultiplePermissionsState(permissions = PermissionCompat.forImages().toList()) { grants ->
if (grants.values.all { it }) {
galleryLauncher.launch(Unit)
} else {
Toast.makeText(this, R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT).show()
}
}
val state by viewModel.state
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
Content(
lifecycleOwner = this,
diposables = disposables.disposables,
state = state,
galleryPermissionsState = galleryPermissionState,
onQrScanned = { url -> viewModel.onQrScanned(url) },
onQrResultHandled = {
finish()
},
onOpenGalleryClicked = {
if (galleryPermissionState.allPermissionsGranted) {
galleryLauncher.launch(Unit)
} else {
galleryPermissionState.launchMultiplePermissionRequest()
}
},
onRecipientFound = { recipient ->
val intent = Intent().apply {
putExtra(KEY_RECIPIENT_ID, recipient.id)
}
setResult(RESULT_OK, intent)
finish()
},
onBackNavigationPressed = {
finish()
}
)
}
}
}
class Contract : ActivityResultContract<Unit, RecipientId?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, UsernameQrScannerActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): RecipientId? {
return intent?.getParcelableExtraCompat(KEY_RECIPIENT_ID, RecipientId::class.java)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Content(
lifecycleOwner: LifecycleOwner,
diposables: CompositeDisposable,
state: UsernameQrScannerViewModel.ScannerState,
galleryPermissionsState: MultiplePermissionsState,
onQrScanned: (String) -> Unit,
onQrResultHandled: () -> Unit,
onOpenGalleryClicked: () -> Unit,
onRecipientFound: (Recipient) -> Unit,
onBackNavigationPressed: () -> Unit
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {},
navigationIcon = {
IconButton(
onClick = onBackNavigationPressed
) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
contentDescription = stringResource(android.R.string.cancel)
)
}
}
)
}
) { contentPadding ->
UsernameQrScanScreen(
lifecycleOwner = lifecycleOwner,
disposables = diposables,
qrScanResult = state.qrScanResult,
onQrCodeScanned = onQrScanned,
onQrResultHandled = onQrResultHandled,
onOpenGalleryClicked = onOpenGalleryClicked,
onRecipientFound = onRecipientFound,
modifier = Modifier.padding(contentPadding)
)
if (state.indeterminateProgress) {
Dialogs.IndeterminateProgressDialog()
}
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Context
import android.net.Uri
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.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
class UsernameQrScannerViewModel : ViewModel() {
private val _state = mutableStateOf(ScannerState(qrScanResult = null, indeterminateProgress = false))
val state: State<ScannerState> = _state
private val disposables = CompositeDisposable()
fun onQrScanned(url: String) {
_state.value = state.value.copy(indeterminateProgress = true)
disposables += UsernameQrScanRepository.lookupUsernameUrl(url)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
_state.value = _state.value.copy(
qrScanResult = result,
indeterminateProgress = false
)
}
}
fun onQrImageSelected(context: Context, uri: Uri) {
_state.value = state.value.copy(indeterminateProgress = true)
disposables += UsernameQrScanRepository.scanImageUriForQrCode(context, uri)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { result ->
_state.value = _state.value.copy(
qrScanResult = result,
indeterminateProgress = false
)
}
}
override fun onCleared() {
disposables.clear()
}
data class ScannerState(
val qrScanResult: QrScanResult?,
val indeterminateProgress: Boolean
)
}

View file

@ -325,15 +325,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
return if (capabilities != null) {
TextUtils.concat(
colorize("GV1Migration", capabilities.groupsV1MigrationCapability),
", ",
colorize("AnnouncementGroup", capabilities.announcementGroupCapability),
", ",
colorize("SenderKey", capabilities.senderKeyCapability),
", ",
colorize("ChangeNumber", capabilities.changeNumberCapability),
", ",
colorize("Stories", capabilities.storiesCapability)
colorize("PNP/PNI", capabilities.pnpCapability),
colorize("PaymentActivation", capabilities.paymentActivation)
)
} else {
"Recipient not found!"

View file

@ -2,14 +2,14 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.content.ClipData
import android.content.Context
import android.graphics.drawable.InsetDrawable
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import android.widget.Toast
import org.signal.core.util.dp
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.ServiceUtil
@ -47,7 +47,7 @@ object BioTextPreference {
val name = if (recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
recipient.getDisplayNameOrUsername(context)
recipient.getDisplayName(context)
}
if (!recipient.showVerified() && !recipient.isIndividual) {
@ -56,16 +56,34 @@ object BioTextPreference {
return SpannableStringBuilder(name).apply {
if (recipient.showVerified()) {
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
SpanUtil.appendSpacer(this, 8)
SpanUtil.appendCenteredImageSpanWithoutSpace(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
} else if (recipient.isSystemContact) {
val systemContactGlyph = SignalSymbols.getSpannedString(
context,
SignalSymbols.Weight.BOLD,
SignalSymbols.Glyph.PERSON_CIRCLE
).let {
SpanUtil.ofSize(it, 20)
}
append(" ")
append(systemContactGlyph)
}
if (recipient.isIndividual && !recipient.isSelf) {
val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_chevron_right_24_color_on_secondary_container)
drawable.setBounds(0, 0, 24.dp, 24.dp)
val chevronGlyph = SignalSymbols.getSpannedString(
context,
SignalSymbols.Weight.BOLD,
SignalSymbols.Glyph.CHEVRON_RIGHT
).let {
SpanUtil.ofSize(it, 24)
}.let {
SpanUtil.color(ContextCompat.getColor(context, R.color.signal_colorOutline), it)
}
val insetDrawable = InsetDrawable(drawable, 0, 0, 0, 4.dp)
SpanUtil.appendBottomImageSpan(this, insetDrawable, 24, 28)
append(" ")
append(chevronGlyph)
}
}
}

View file

@ -1,12 +1,16 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@ -56,7 +60,16 @@ object RecipientPreference {
name.text = if (model.recipient.isSelf) {
context.getString(R.string.Recipient_you)
} else {
model.recipient.getDisplayName(context)
if (model.recipient.isSystemContact) {
SpannableStringBuilder(model.recipient.getDisplayName(context)).apply {
val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
model.recipient.getDisplayName(context)
}
}
val aboutText = model.recipient.combinedAboutAndEmoji

View file

@ -51,8 +51,8 @@ class TransferProgressView @JvmOverloads constructor(
private val progressRect = RectF()
private val stopIconRect = RectF()
private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_down_24)
private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_up_16)
private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_down_24)
private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_up_24)
private var progressPercent = 0f
private var currentState = State.UNINITIALIZED

View file

@ -16,6 +16,7 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.core.view.GestureDetectorCompat;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
@ -24,9 +25,10 @@ import java.util.Arrays;
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
private static final float DECELERATION_RATE = 0.99f;
private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator();
private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator();
private static final float DECELERATION_RATE = 0.99f;
private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator();
private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator();
private static final int HORIZONTAL_PARTICIPANTS_CONTAINER_HEIGHT = (int) DimensionUnit.DP.toPixels(36);
private final ViewGroup parent;
private final View child;
@ -34,7 +36,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
private int pipWidth;
private int pipHeight;
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
private float lastTouchX;
private float lastTouchY;
private int extraPaddingTop;
@ -45,9 +47,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
private int maximumFlingVelocity;
private boolean isLockedToBottomEnd;
private Interpolator interpolator;
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
private int previousTopBoundary = -1;
private int previousBottomBoundary = -1;
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
private int previousTopBoundary = -1;
private int previousBottomBoundary = -1;
private boolean displayBelowVerticalBoundary = false;
@SuppressLint("ClickableViewAccessibility")
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
@ -132,9 +135,27 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
if (displayBelowVerticalBoundary) {
extraPaddingBottom -= (int) DimensionUnit.DP.toPixels(HORIZONTAL_PARTICIPANTS_CONTAINER_HEIGHT);
}
ViewUtil.setBottomMargin(child, extraPaddingBottom + framePadding);
}
public void setDisplayBelowVerticalBoundary(boolean displayBelowVerticalBoundary) {
if (this.displayBelowVerticalBoundary == displayBelowVerticalBoundary) {
return;
}
this.displayBelowVerticalBoundary = displayBelowVerticalBoundary;
int bottomBoundary = previousBottomBoundary;
previousBottomBoundary = -1;
setBottomVerticalBoundary(bottomBoundary);
}
private boolean onGestureFinished(MotionEvent e) {
final int pointerIndex = e.findPointerIndex(activePointerId);
@ -301,7 +322,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
* User drag is implemented by translating the view from the current gravity anchor (corner). When the user drags
* to a new corner, we need to adjust the translations for the new corner so the animation of translation X/Y to 0
* works correctly.
*
* <p>
* For example, if in bottom right and need to move to top right, we need to calculate a new translation Y since instead
* of being translated up from bottom it's translated down from the top.
*/

View file

@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CameraState;
@ -441,7 +442,13 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
if (state.getGroupCallState().isNotIdle()) {
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
callLinkWarningCard.setVisibility(callParticipantsViewState.isStartedFromCallLink() ? View.VISIBLE : View.GONE);
if (callParticipantsViewState.isStartedFromCallLink()) {
TextView warningTextView = callLinkWarningCard.get().findViewById(R.id.call_screen_call_link_warning_textview);
warningTextView.setText(SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled() ? R.string.WebRtcCallView__anyone_who_joins_pnp_enabled : R.string.WebRtcCallView__anyone_who_joins_pnp_disabled);
callLinkWarningCard.setVisibility(View.VISIBLE);
} else {
callLinkWarningCard.setVisibility(View.GONE);
}
setStatus(state.getPreJoinGroupDescription(getContext()));
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
callLinkWarningCard.setVisibility(View.GONE);
@ -514,6 +521,8 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
}
pictureInPictureGestureHelper.setDisplayBelowVerticalBoundary(false);
switch (state) {
case GONE:
largeLocalRender.attachBroadcastVideoSink(null);
@ -534,6 +543,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
pictureInPictureGestureHelper.setDisplayBelowVerticalBoundary(true);
break;
case LARGE:
largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink());
@ -835,6 +845,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
previousLayoutPositions = layoutPositions;
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.setForceId(false);
constraintSet.clone(this);
constraintSet.connect(R.id.call_screen_participants_parent,
@ -868,6 +879,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private void updatePendingParticipantsBottomConstraint(View anchor) {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.setForceId(false);
constraintSet.clone(this);
constraintSet.connect(R.id.call_screen_pending_recipients,

View file

@ -52,7 +52,6 @@ import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@ -165,8 +164,16 @@ private fun CallInfo(
modifier = modifier
) {
item {
val text = if (controlAndInfoState.callLink == null) {
stringResource(id = R.string.CallLinkInfoSheet__call_info)
} else if (controlAndInfoState.callLink.state.name.isNotEmpty()) {
controlAndInfoState.callLink.state.name
} else {
stringResource(id = R.string.Recipient_signal_call)
}
Text(
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 24.dp)
)
@ -174,8 +181,6 @@ private fun CallInfo(
if (controlAndInfoState.callLink != null) {
item {
SignalCallRow(callLink = controlAndInfoState.callLink, onJoinClicked = null)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
@ -224,8 +229,7 @@ private fun CallInfo(
}
}
var includeAdminControlsDivider = true
if (controlAndInfoState.callLink == null || participantsState.isOngoing()) {
if (!participantsState.inCallLobby || participantsState.isOngoing()) {
item {
Box(
modifier = Modifier
@ -240,8 +244,6 @@ private fun CallInfo(
)
}
}
} else {
includeAdminControlsDivider = false
}
if (!participantsState.inCallLobby || participantsState.isOngoing()) {
@ -285,12 +287,16 @@ private fun CallInfo(
if (controlAndInfoState.callLink?.credentials?.adminPassBytes != null) {
item {
if (includeAdminControlsDivider) {
if (!participantsState.inCallLobby) {
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
text = if (controlAndInfoState.callLink.state.name.isNotEmpty()) {
stringResource(id = R.string.CallLinkDetailsFragment__edit_call_name)
} else {
stringResource(id = R.string.CallLinkDetailsFragment__add_call_name)
},
onClick = onEditNameClicked
)
Rows.ToggleRow(

View file

@ -393,7 +393,7 @@ class ControlsAndInfoController(
controlsAndInfoViewModel.setApproveAllMembers(checked)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
@ -419,7 +419,7 @@ class ControlsAndInfoController(
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}

View file

@ -11,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.libsignal.protocol.util.Pair;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
@ -108,15 +109,15 @@ public class ContactRepository {
@WorkerThread
public @NonNull Cursor querySignalContacts(@NonNull String query) {
return querySignalContacts(query, true);
return querySignalContacts(new RecipientTable.ContactSearchQuery(query, true, ContactSearchSortOrder.NATURAL));
}
@WorkerThread
public @NonNull Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getSignalContacts(includeSelf)
: recipientTable.querySignalContacts(query, includeSelf);
public @NonNull Cursor querySignalContacts(@NonNull RecipientTable.ContactSearchQuery contactSearchQuery) {
Cursor cursor = TextUtils.isEmpty(contactSearchQuery.getQuery()) ? recipientTable.getSignalContacts(contactSearchQuery.getIncludeSelf())
: recipientTable.querySignalContacts(contactSearchQuery);
cursor = handleNoteToSelfQuery(query, includeSelf, cursor);
cursor = handleNoteToSelfQuery(contactSearchQuery.getQuery(), contactSearchQuery.getIncludeSelf(), cursor);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}

View file

@ -30,7 +30,7 @@ object SelectedContacts {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: Model) {
chip.text = model.recipient.getShortDisplayNameIncludingUsername(context)
chip.text = model.recipient.getShortDisplayName(context)
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
chip.setOnCloseIconClickListener {

View file

@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
@ -28,6 +30,8 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@ -390,19 +394,41 @@ open class ContactSearchAdapter(
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
private val headerGroup: View = itemView.findViewById(R.id.contact_header)
private val headerText: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
name.setText(
when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> R.string.contact_selection_list__unknown_contact
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
)
number.text = model.data.query
val nameText = when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
if (nameText > 0) {
name.setText(nameText)
number.text = model.data.query
number.visible = true
} else {
name.text = model.data.query
number.visible = false
}
if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
headerGroup.visible = true
headerText.setText(
if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
R.string.FindByActivity__find_by_phone_number
} else {
R.string.FindByActivity__find_by_username
}
)
} else {
headerGroup.visible = false
}
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
@ -500,7 +526,19 @@ open class ContactSearchAdapter(
return
}
name.setText(getRecipient(model))
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified()) {
SpannableStringBuilder().apply {
val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
null
}
name.setText(recipient, suffix)
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)

View file

@ -69,6 +69,8 @@ class ContactSearchConfiguration private constructor(
/**
* 1:1 Recipients with whom the user has started a conversation.
*
* Note that sort order is only respected when returning a query result for signal-only contacts. In all other cases, natural ordering is used.
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
@ -78,7 +80,8 @@ class ContactSearchConfiguration private constructor(
val transportType: TransportType,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false
val includeLetterHeaders: Boolean = false,
val pushSearchResultsSortOrder: ContactSearchSortOrder = ContactSearchSortOrder.NATURAL
) : Section(SectionKey.INDIVIDUALS)
/**

View file

@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterat
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
@ -210,7 +211,10 @@ class ContactSearchPagedDataSource(
private fun getNonGroupSearchIterator(section: ContactSearchConfiguration.Section.Individuals, query: String?): ContactSearchIterator<Cursor> {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)))
ContactSearchConfiguration.TransportType.PUSH -> {
val searchQuery = RecipientTable.ContactSearchQuery(query ?: "", section.includeSelf, section.pushSearchResultsSortOrder)
CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.querySignalContacts(searchQuery)))
}
ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)))
ContactSearchConfiguration.TransportType.ALL -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)))
}

View file

@ -34,8 +34,8 @@ open class ContactSearchPagedDataSourceRepository(
.getLatestActiveStorySendTimestamps(System.currentTimeMillis() - activeStoryCutoffDuration)
}
open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? {
return contactRepository.querySignalContacts(query ?: "", includeSelf)
open fun querySignalContacts(contactsSearchQuery: RecipientTable.ContactSearchQuery): Cursor? {
return contactRepository.querySignalContacts(contactsSearchQuery)
}
open fun querySignalContactLetterHeaders(query: String?, includeSelf: Boolean, includePush: Boolean, includeSms: Boolean): Map<RecipientId, String> {

View file

@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.util.UuidUtil
import java.io.IOException
@ -112,6 +113,19 @@ object ContactDiscovery {
}
}
/**
* Looks up the PNI/ACI for an E164. Only creates a recipient if the number is in the CDS directory.
* Use sparingly! This will always use up the user's CDS quota. Always prefer other syncing methods for bulk lookups.
*
* Returns a [LookupResult] if the E164 is in the CDS directory, or null if it is not.
* Important: Just because a user is not in the directory does not mean they are not registered. They could have discoverability off.
*/
@Throws(IOException::class)
@WorkerThread
fun lookupE164(e164: String): LookupResult? {
return ContactDiscoveryRefreshV2.lookupE164(e164)
}
@JvmStatic
@WorkerThread
fun syncRecipientInfoWithSystemContacts(context: Context) {
@ -278,7 +292,7 @@ object ContactDiscovery {
/**
* Whether or not a session exists with the provided recipient.
*/
fun hasSession(id: RecipientId): Boolean {
private fun hasSession(id: RecipientId): Boolean {
val recipient = Recipient.resolved(id)
if (!recipient.hasServiceId()) {
@ -295,4 +309,10 @@ object ContactDiscovery {
val registeredIds: Set<RecipientId>,
val rewrites: Map<String, String>
)
data class LookupResult(
val recipientId: RecipientId,
val pni: ServiceId.PNI,
val aci: ServiceId.ACI?
)
}

View file

@ -86,6 +86,42 @@ object ContactDiscoveryRefreshV2 {
}
}
@Throws(IOException::class)
@WorkerThread
@Synchronized
fun lookupE164(e164: String): ContactDiscovery.LookupResult? {
val response: CdsiV2Service.Response = try {
ApplicationDependencies.getSignalServiceAccountManager().getRegisteredUsersWithCdsi(
emptySet(),
setOf(e164),
SignalDatabase.recipients.getAllServiceIdProfileKeyPairs(),
Optional.empty(),
BuildConfig.CDSI_MRENCLAVE,
10_000,
if (FeatureFlags.useLibsignalNetForCdsiLookup()) BuildConfig.LIBSIGNAL_NET_ENV else null
) {
Log.i(TAG, "Ignoring token for one-off lookup.")
}
} catch (e: CdsiResourceExhaustedException) {
Log.w(TAG, "CDS resource exhausted! Can try again in ${e.retryAfterSeconds} seconds.")
SignalStore.misc().cdsBlockedUtil = System.currentTimeMillis() + e.retryAfterSeconds.seconds.inWholeMilliseconds
throw e
} catch (e: CdsiInvalidTokenException) {
Log.w(TAG, "We did not provide a token, but still got a token error! Unexpected, but ignoring.")
throw e
}
return response.results[e164]?.let { item ->
val id = SignalDatabase.recipients.processIndividualCdsLookup(e164 = e164, aci = item.aci.orElse(null), pni = item.pni)
ContactDiscovery.LookupResult(
recipientId = id,
pni = item.pni,
aci = item.aci?.orElse(null)
)
}
}
@Throws(IOException::class)
private fun refreshInternal(
recipientE164s: Set<String>,
@ -126,7 +162,8 @@ object ContactDiscoveryRefreshV2 {
SignalDatabase.recipients.getAllServiceIdProfileKeyPairs(),
Optional.ofNullable(token),
BuildConfig.CDSI_MRENCLAVE,
timeoutMs
timeoutMs,
if (FeatureFlags.useLibsignalNetForCdsiLookup()) BuildConfig.LIBSIGNAL_NET_ENV else null
) { tokenToSave ->
stopwatch.split("network-pre-token")
if (!isPartialRefresh) {

View file

@ -76,7 +76,7 @@ public class ConversationHeaderView extends ConstraintLayout {
}
public String setTitle(@NonNull Recipient recipient) {
SpannableStringBuilder title = new SpannableStringBuilder(recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(getContext()));
SpannableStringBuilder title = new SpannableStringBuilder(recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayName(getContext()));
if (recipient.showVerified()) {
SpanUtil.appendCenteredImageSpan(title, ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_28), 28, 28);
}
@ -177,12 +177,12 @@ public class ConversationHeaderView extends ConstraintLayout {
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes) {
Drawable drawable = ContextCompat.getDrawable(getContext(), iconRes);
Preconditions.checkNotNull(drawable);
drawable.setBounds(0, 0, (int) DimensionUnit.DP.toPixels(20), (int) DimensionUnit.DP.toPixels(20));
drawable.setBounds(0, 0, (int) DimensionUnit.SP.toPixels(20), (int) DimensionUnit.SP.toPixels(20));
drawable.setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface), PorterDuff.Mode.SRC_ATOP);
return new SpannableStringBuilder()
.append(SpanUtil.buildCenteredImageSpan(drawable))
.append(SpanUtil.space(8, DimensionUnit.DP))
.append(SpanUtil.space(8, DimensionUnit.SP))
.append(input);
}

View file

@ -207,7 +207,7 @@ public class ConversationTitleView extends ConstraintLayout {
}
private void setIndividualRecipientTitle(@NonNull Recipient recipient) {
final String displayName = recipient.getDisplayNameOrUsername(getContext());
final String displayName = recipient.getDisplayName(getContext());
this.title.setText(displayName);
this.subtitle.setText(null);
updateSubtitleVisibility();

View file

@ -2827,11 +2827,8 @@ class ConversationFragment :
}
override fun onInviteToSignalClicked() {
val recipient = viewModel.recipientSnapshot ?: return
InviteActions.inviteUserToSignal(
requireContext(),
recipient,
binding.conversationInputPanel.embeddedTextEditor::appendInvite,
this@ConversationFragment::startActivity
)
}
@ -3267,8 +3264,6 @@ class ConversationFragment :
InviteActions.inviteUserToSignal(
context = requireContext(),
recipient = recipient,
appendInviteToComposer = composeText::appendInvite,
launchIntent = this@ConversationFragment::startActivity
)
}
@ -3681,8 +3676,6 @@ class ConversationFragment :
override fun onInviteToSignal(recipient: Recipient) {
InviteActions.inviteUserToSignal(
context = requireContext(),
recipient = recipient,
appendInviteToComposer = null,
launchIntent = this@ConversationFragment::startActivity
)
}

View file

@ -238,7 +238,7 @@ class ConversationViewModel(
messageRequestState = messageRequestRepository.getMessageRequestState(recipient, threadId),
groupRecord = groupRecord.orNull(),
isClientExpired = SignalStore.misc().isClientDeprecated,
isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())
isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
)
}.doOnNext {
hasMessageRequestStateSubject.onNext(it.messageRequestState)

View file

@ -18,7 +18,7 @@ class InputReadyState(
val messageRequestState: MessageRequestState,
val groupRecord: GroupRecord?,
val isClientExpired: Boolean,
val isUnauthorized: Boolean
val isUnauthorized: Boolean,
) {
private val selfMemberLevel: GroupTable.MemberLevel? = groupRecord?.memberLevel(Recipient.self())

View file

@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.conversationlist;
import android.content.Context;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@ -78,6 +79,7 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
@ -211,7 +213,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations)
{
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null);
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null, false);
}
public void bindThread(@NonNull LifecycleOwner lifecycleOwner,
@ -220,7 +222,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
@Nullable String highlightSubstring)
@Nullable String highlightSubstring,
boolean appendSystemContactIcon)
{
this.threadId = thread.getThreadId();
this.requestManager = requestManager;
@ -234,12 +237,20 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
SpannableStringBuilder suffix = null;
if (appendSystemContactIcon && recipient.get().isSystemContact() && !recipient.get().showVerified()) {
suffix = new SpannableStringBuilder();
Drawable drawable = ContextUtil.requireDrawable(getContext(), R.drawable.symbol_person_circle_24);
drawable.setTint(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface));
SpanUtil.appendCenteredImageSpan(suffix, drawable, 16, 16);
}
if (highlightSubstring != null) {
String name = recipient.get().isSelf() ? getContext().getString(R.string.note_to_self) : recipient.get().getDisplayName(getContext());
this.fromView.setText(recipient.get(), SearchUtil.getHighlightedSpan(locale, searchStyleFactory, name, highlightSubstring, SearchUtil.MATCH_ALL), true, null);
this.fromView.setText(recipient.get(), SearchUtil.getHighlightedSpan(locale, searchStyleFactory, name, highlightSubstring, SearchUtil.MATCH_ALL), suffix);
} else {
this.fromView.setText(recipient.get(), false);
this.fromView.setText(recipient.get(), suffix);
}
this.typingThreads = typingThreads;
@ -299,7 +310,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
joinMembersDisposable.dispose();
setSubjectViewText(null);
fromView.setText(recipient.get(), recipient.get().getDisplayNameOrUsername(getContext()), false, null, false);
fromView.setText(recipient.get(), recipient.get().getDisplayName(getContext()), null, false);
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, messageResult.getBodySnippet(), highlightSubstring, SearchUtil.MATCH_ALL));
updateDateView = () -> dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.getReceivedTimestampMs()));
@ -332,9 +343,15 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, joined, highlightSubstring, SearchUtil.MATCH_ALL));
});
fromView.setText(recipient.get(), false);
fromView.setText(recipient.get());
updateDateView = () -> dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, groupWithMembers.getDate()));
updateDateView = () -> {
if (groupWithMembers.getDate() > 0) {
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, groupWithMembers.getDate()));
} else {
dateView.setText("");
}
};
updateDateView.run();
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
@ -548,9 +565,9 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
} else {
name = recipient.getDisplayName(getContext());
}
fromView.setText(recipient, SearchUtil.getHighlightedSpan(locale, searchStyleFactory, new SpannableString(name), highlightSubstring, SearchUtil.MATCH_ALL), true, null, thread != null);
fromView.setText(recipient, SearchUtil.getHighlightedSpan(locale, searchStyleFactory, new SpannableString(name), highlightSubstring, SearchUtil.MATCH_ALL), null, thread != null);
} else {
fromView.setText(recipient, false);
fromView.setText(recipient);
}
contactPhotoImage.setAvatar(requestManager, recipient, !batchMode, false);
setBadgeFromRecipient(recipient);

View file

@ -120,7 +120,8 @@ class ConversationListSearchAdapter(
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query
model.thread.query,
true
)
}
}

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