|
@ -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
|
||||
|
||||
|
|
|
@ -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\"")
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 127 KiB |
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 176 KiB |
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 120 KiB |
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 127 KiB |
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 209 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 137 KiB |
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 194 KiB |
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 213 KiB |
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 210 KiB |
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 221 KiB |
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 202 KiB |
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 298 KiB |
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 199 KiB |
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 111 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 208 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 100 KiB |
BIN
app/src/main/assets/fonts/SignalSymbols-Bold.otf
Normal 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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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) -> {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -279,6 +279,7 @@ class ChangeNumberRepository(
|
|||
)
|
||||
)
|
||||
pniMetadataStore.isSignedPreKeyRegistered = true
|
||||
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId
|
||||
|
||||
pniProtocolStore.identities().saveIdentityWithoutSideEffects(
|
||||
Recipient.self().id,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -13,4 +13,6 @@ sealed class QrScanResult {
|
|||
object InvalidData : QrScanResult()
|
||||
|
||||
object NetworkError : QrScanResult()
|
||||
|
||||
object QrNotFound : QrScanResult()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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!"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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?
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -120,7 +120,8 @@ class ConversationListSearchAdapter(
|
|||
Locale.getDefault(),
|
||||
emptySet(),
|
||||
ConversationSet(),
|
||||
model.thread.query
|
||||
model.thread.query,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|