Merge tag 'v7.2.4' into molly-7.2

This commit is contained in:
Oscar Mira 2024-04-04 10:43:44 +02:00
commit 1d4fea6aad
270 changed files with 12321 additions and 2475 deletions

View file

@ -19,8 +19,8 @@ apply {
from("fix-profm.gradle")
}
val canonicalVersionCode = 1400
val canonicalVersionName = "7.1.3"
val canonicalVersionCode = 1405
val canonicalVersionName = "7.2.4"
val mollyRevision = 1
val postFixSize = 100
@ -189,7 +189,7 @@ android {
buildConfigField("String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\"")
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=\"")
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/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().map { "\"$it\"" }.joinToString(separator = ", ")} }")
@ -202,6 +202,7 @@ android {
// 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/\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false")
ndk {
//noinspection ChromeOsAbiSupport
@ -335,7 +336,7 @@ android {
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
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=\"")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"")
@ -345,6 +346,7 @@ android {
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
}
}
@ -443,6 +445,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.extensions)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.concurrent.futures)

View file

@ -1,7 +1,9 @@
-dontobfuscate
-keepattributes SourceFile,LineNumberTable
-keep class org.whispersystems.** { *; }
-keep class org.signal.libsignal.net.** { *; }
-keep class org.signal.libsignal.protocol.** { *; }
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keepclassmembers class ** {

View file

@ -51,18 +51,16 @@ class AttachmentTableTest {
SignalDatabase.attachments.updateAttachmentData(
attachment,
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
false
createMediaStream(byteArrayOf(1, 2, 3, 4, 5))
)
SignalDatabase.attachments.updateAttachmentData(
attachment2,
createMediaStream(byteArrayOf(1, 2, 3)),
false
createMediaStream(byteArrayOf(1, 2, 3))
)
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId)
val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId)
assertNotEquals(attachment1Info, attachment2Info)
}
@ -79,18 +77,16 @@ class AttachmentTableTest {
SignalDatabase.attachments.updateAttachmentData(
attachment,
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
true
createMediaStream(byteArrayOf(1, 2, 3, 4, 5))
)
SignalDatabase.attachments.updateAttachmentData(
attachment2,
createMediaStream(byteArrayOf(1, 2, 3, 4)),
true
createMediaStream(byteArrayOf(1, 2, 3, 4))
)
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId)
val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId)
assertNotEquals(attachment1Info, attachment2Info)
}
@ -121,15 +117,14 @@ class AttachmentTableTest {
val highDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityPreUpload)
// WHEN
SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData), false)
SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData))
// THEN
val previousInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(previousDatabaseAttachmentId, AttachmentTable.DATA_FILE)!!
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
val previousInfo = SignalDatabase.attachments.getDataFileInfo(previousDatabaseAttachmentId)!!
val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!!
val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!!
assertNotEquals(standardInfo, highInfo)
standardInfo.file assertIs previousInfo.file
highInfo.file assertIsNot standardInfo.file
highInfo.file.exists() assertIs true
}
@ -158,9 +153,9 @@ class AttachmentTableTest {
val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload)
// THEN
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!!
val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!!
val secondHighInfo = SignalDatabase.attachments.getDataFileInfo(secondHighDatabaseAttachment.attachmentId)!!
highInfo.file assertIsNot standardInfo.file
secondHighInfo.file assertIs highInfo.file

View file

@ -0,0 +1,804 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.push.ServiceId
import java.io.File
import java.util.UUID
import kotlin.random.Random
import kotlin.time.Duration.Companion.days
/**
* Collection of [AttachmentTable] tests focused around deduping logic.
*/
@RunWith(AndroidJUnit4::class)
class AttachmentTableTest_deduping {
companion object {
val DATA_A = byteArrayOf(1, 2, 3)
val DATA_A_COMPRESSED = byteArrayOf(4, 5, 6)
val DATA_A_HASH = byteArrayOf(1, 1, 1)
val DATA_B = byteArrayOf(7, 8, 9)
}
@Before
fun setUp() {
SignalStore.account().setAci(ServiceId.ACI.from(UUID.randomUUID()))
SignalStore.account().setPni(ServiceId.PNI.from(UUID.randomUUID()))
SignalStore.account().setE164("+15558675309")
SignalDatabase.attachments.deleteAllAttachments()
}
/**
* Creates two different files with different data. Should not dedupe.
*/
@Test
fun differentFiles() {
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_B)
assertDataFilesAreDifferent(id1, id2)
}
}
/**
* Inserts files with identical data but with transform properties that make them incompatible. Should not dedupe.
*/
@Test
fun identicalFiles_incompatibleTransforms() {
// Non-matching qualities
test {
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Non-matching video trim flag
test {
val id1 = insertWithData(DATA_A, TransformProperties())
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Non-matching video trim start time
test {
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Non-matching video trim end time
test {
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 1))
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Non-matching mp4 fast start
test {
val id1 = insertWithData(DATA_A, TransformProperties(mp4FastStart = true))
val id2 = insertWithData(DATA_A, TransformProperties(mp4FastStart = false))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
}
/**
* Inserts files with identical data and compatible transform properties. Should dedupe.
*/
@Test
fun identicalFiles_compatibleTransforms() {
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, false)
assertSkipTransform(id2, false)
}
test {
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, false)
assertSkipTransform(id2, false)
}
test {
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, false)
assertSkipTransform(id2, false)
}
test {
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, false)
assertSkipTransform(id2, false)
}
}
/**
* Walks through various scenarios where files are compressed and uploaded.
*/
@Test
fun compressionAndUploads() {
// Matches after the first is compressed, skip transform properly set
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
}
// Matches after the first is uploaded, skip transform and ending hash properly set
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
upload(id1)
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
}
// Mimics sending two files at once. Ensures all fields are kept in sync as we compress and upload.
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, false)
assertSkipTransform(id2, false)
compress(id1, DATA_A_COMPRESSED)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
upload(id1)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertRemoteFieldsMatch(id1, id2)
}
// Re-use the upload when uploaded recently
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertRemoteFieldsMatch(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
}
// Do not re-use old uploads
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis() - 100.days.inWholeMilliseconds)
val id2 = insertWithData(DATA_A)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
assertDoesNotHaveRemoteFields(id2)
}
// This isn't so much "desirable behavior" as it is documenting how things work.
// If an attachment is compressed but not uploaded yet, it will have a DATA_HASH_START that doesn't match the actual file content.
// This means that if we insert a new attachment with data that matches the compressed data, we won't find a match.
// This is ok because we don't allow forwarding unsent messages, so the chances of the user somehow sending a file that matches data we compressed are very low.
// What *is* more common is that the user may send DATA_A again, and in this case we will still catch the dedupe (which is already tested above).
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
val id2 = insertWithData(DATA_A_COMPRESSED)
assertDataFilesAreDifferent(id1, id2)
}
// This represents what would happen if you forward an already-send compressed attachment. We should match, skip transform, and skip upload.
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A_COMPRESSED)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id1, true)
assertRemoteFieldsMatch(id1, id2)
}
// This represents what would happen if you edited a video, sent it, then forwarded it. We should match, skip transform, and skip upload.
test {
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A_COMPRESSED)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id1, true)
assertRemoteFieldsMatch(id1, id2)
}
// This represents what would happen if you edited a video, sent it, then forwarded it, but *edited the forwarded video*. We should not dedupe.
test {
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
assertDataFilesAreDifferent(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, false)
assertDoesNotHaveRemoteFields(id2)
}
// This represents what would happen if you sent an image using standard quality, then forwarded it using high quality.
// Since you're forwarding, it doesn't matter if the new thing has a higher quality, we should still match and skip transform.
test {
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id1, true)
assertRemoteFieldsMatch(id1, id2)
}
// This represents what would happen if you sent an image using high quality, then forwarded it using standard quality.
// Since you're forwarding, it doesn't matter if the new thing has a lower quality, we should still match and skip transform.
test {
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
compress(id1, DATA_A_COMPRESSED)
upload(id1, uploadTimestamp = System.currentTimeMillis())
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id1, true)
assertRemoteFieldsMatch(id1, id2)
}
// Make sure that files marked as unhashable are all updated together
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
upload(id1)
upload(id2)
clearHashes(id1)
clearHashes(id2)
val file = dataFile(id1)
SignalDatabase.attachments.markDataFileAsUnhashable(file)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(id1)!!
assertTrue(dataFileInfo.hashEnd!!.startsWith("UNHASHABLE-"))
}
}
/**
* Various deletion scenarios to ensure that duped files don't deleted while there's still references.
*/
@Test
fun deletions() {
// Delete original then dupe
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
val dataFile = dataFile(id1)
assertDataFilesAreTheSame(id1, id2)
delete(id1)
assertDeleted(id1)
assertRowAndFileExists(id2)
assertTrue(dataFile.exists())
delete(id2)
assertDeleted(id2)
assertFalse(dataFile.exists())
}
// Delete dupe then original
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
val dataFile = dataFile(id1)
assertDataFilesAreTheSame(id1, id2)
delete(id2)
assertDeleted(id2)
assertRowAndFileExists(id1)
assertTrue(dataFile.exists())
delete(id1)
assertDeleted(id1)
assertFalse(dataFile.exists())
}
// Delete original after it was compressed
test {
val id1 = insertWithData(DATA_A)
compress(id1, DATA_A_COMPRESSED)
val id2 = insertWithData(DATA_A)
delete(id1)
assertDeleted(id1)
assertRowAndFileExists(id2)
assertSkipTransform(id2, true)
}
// Quotes are weak references and should not prevent us from deleting the file
test {
val id1 = insertWithData(DATA_A)
val id2 = insertQuote(id1)
val dataFile = dataFile(id1)
delete(id1)
assertDeleted(id1)
assertRowExists(id2)
assertFalse(dataFile.exists())
}
}
@Test
fun quotes() {
// Basic quote deduping
test {
val id1 = insertWithData(DATA_A)
val id2 = insertQuote(id1)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Making sure remote fields carry
test {
val id1 = insertWithData(DATA_A)
val id2 = insertQuote(id1)
upload(id1)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertRemoteFieldsMatch(id1, id2)
}
// Making sure things work for quotes of videos, which have trickier transform properties
test {
val id1 = insertWithData(DATA_A, transformProperties = TransformProperties.forVideoTrim(1, 2))
compress(id1, DATA_A_COMPRESSED)
upload(id1)
val id2 = insertQuote(id1)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertRemoteFieldsMatch(id1, id2)
}
}
/**
* Suite of tests around the migration where we hash all of the attachments and potentially dedupe them.
*/
@Test
fun migration() {
// Verifying that getUnhashedDataFile only returns if there's actually missing hashes
test {
val id = insertWithData(DATA_A)
upload(id)
assertNull(SignalDatabase.attachments.getUnhashedDataFile())
}
// Verifying that getUnhashedDataFile finds the missing hash
test {
val id = insertWithData(DATA_A)
upload(id)
clearHashes(id)
assertNotNull(SignalDatabase.attachments.getUnhashedDataFile())
}
// Verifying that getUnhashedDataFile doesn't return if the file isn't done downloading
test {
val id = insertWithData(DATA_A)
upload(id)
setTransferState(id, AttachmentTable.TRANSFER_PROGRESS_PENDING)
clearHashes(id)
assertNull(SignalDatabase.attachments.getUnhashedDataFile())
}
// If two attachments share the same file, when we backfill the hash, make sure both get their hashes set
test {
val id1 = insertWithData(DATA_A)
val id2 = insertWithData(DATA_A)
upload(id1)
upload(id2)
clearHashes(id1)
clearHashes(id2)
val file = dataFile(id1)
SignalDatabase.attachments.setHashForDataFile(file, DATA_A_HASH)
assertDataHashEnd(id1, DATA_A_HASH)
assertDataHashEndMatches(id1, id2)
}
// Creates a situation where two different attachments have the same data but wrote to different files, and verifies the migration dedupes it
test {
val id1 = insertWithData(DATA_A)
upload(id1)
clearHashes(id1)
val id2 = insertWithData(DATA_A)
upload(id2)
clearHashes(id2)
assertDataFilesAreDifferent(id1, id2)
val file1 = dataFile(id1)
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
assertDataHashEnd(id1, DATA_A_HASH)
assertDataFilesAreDifferent(id1, id2)
val file2 = dataFile(id2)
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertFalse(file2.exists())
}
// We've got three files now with the same data, with two of them sharing a file. We want to make sure *both* entries that share the same file get deduped.
test {
val id1 = insertWithData(DATA_A)
upload(id1)
clearHashes(id1)
val id2 = insertWithData(DATA_A)
val id3 = insertWithData(DATA_A)
upload(id2)
upload(id3)
clearHashes(id2)
clearHashes(id3)
assertDataFilesAreDifferent(id1, id2)
assertDataFilesAreTheSame(id2, id3)
val file1 = dataFile(id1)
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
assertDataHashEnd(id1, DATA_A_HASH)
val file2 = dataFile(id2)
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
assertDataFilesAreTheSame(id1, id2)
assertDataHashEndMatches(id1, id2)
assertDataHashEndMatches(id2, id3)
assertFalse(file2.exists())
}
// We don't want to mess with files that are still downloading, so this makes sure that even if data matches, we don't dedupe and don't delete the file
test {
val id1 = insertWithData(DATA_A)
upload(id1)
clearHashes(id1)
val id2 = insertWithData(DATA_A)
// *not* uploaded
clearHashes(id2)
assertDataFilesAreDifferent(id1, id2)
val file1 = dataFile(id1)
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
assertDataHashEnd(id1, DATA_A_HASH)
val file2 = dataFile(id2)
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
assertDataFilesAreDifferent(id1, id2)
assertTrue(file2.exists())
}
}
private class TestContext {
fun insertWithData(data: ByteArray, transformProperties: TransformProperties = TransformProperties.empty()): AttachmentId {
val uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val attachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
uri = uri,
contentType = MediaUtil.IMAGE_JPEG,
transformProperties = transformProperties
)
return SignalDatabase.attachments.insertAttachmentForPreUpload(attachment).attachmentId
}
fun insertQuote(attachmentId: AttachmentId): AttachmentId {
val originalAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.self())
val messageId = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(
threadRecipient = Recipient.self(),
sentTimeMillis = System.currentTimeMillis(),
body = "some text",
outgoingQuote = QuoteModel(
id = 123,
author = Recipient.self().id,
text = "Some quote text",
isOriginalMissing = false,
attachments = listOf(originalAttachment),
mentions = emptyList(),
type = QuoteModel.Type.NORMAL,
bodyRanges = null
)
),
threadId = threadId,
forceSms = false,
insertListener = null
)
val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
return attachments[0].attachmentId
}
fun compress(attachmentId: AttachmentId, newData: ByteArray, mp4FastStart: Boolean = false) {
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
SignalDatabase.attachments.updateAttachmentData(databaseAttachment, newData.asMediaStream())
SignalDatabase.attachments.markAttachmentAsTransformed(attachmentId, withFastStart = mp4FastStart)
}
fun upload(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()) {
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createPointerAttachment(attachmentId, uploadTimestamp), uploadTimestamp)
}
fun delete(attachmentId: AttachmentId) {
SignalDatabase.attachments.deleteAttachment(attachmentId)
}
fun dataFile(attachmentId: AttachmentId): File {
return SignalDatabase.attachments.getDataFileInfo(attachmentId)!!.file
}
fun setTransferState(attachmentId: AttachmentId, transferState: Int) {
// messageId doesn't actually matter -- that's for notifying listeners
SignalDatabase.attachments.setTransferState(messageId = -1, attachmentId = attachmentId, transferState = transferState)
}
fun clearHashes(id: AttachmentId) {
SignalDatabase.attachments.writableDatabase
.update(AttachmentTable.TABLE_NAME)
.values(
AttachmentTable.DATA_HASH_START to null,
AttachmentTable.DATA_HASH_END to null
)
.where("${AttachmentTable.ID} = ?", id)
.run()
}
fun assertDeleted(attachmentId: AttachmentId) {
assertNull("$attachmentId exists, but it shouldn't!", SignalDatabase.attachments.getAttachment(attachmentId))
}
fun assertRowAndFileExists(attachmentId: AttachmentId) {
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)
assertNotNull("$attachmentId does not exist!", databaseAttachment)
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(attachmentId)
assertTrue("The file for $attachmentId does not exist!", dataFileInfo!!.file.exists())
}
fun assertRowExists(attachmentId: AttachmentId) {
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)
assertNotNull("$attachmentId does not exist!", databaseAttachment)
}
fun assertDataFilesAreTheSame(lhs: AttachmentId, rhs: AttachmentId) {
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
assert(lhsInfo.file.exists())
assert(rhsInfo.file.exists())
assertEquals(lhsInfo.file, rhsInfo.file)
assertEquals(lhsInfo.length, rhsInfo.length)
assertArrayEquals(lhsInfo.random, rhsInfo.random)
}
fun assertDataFilesAreDifferent(lhs: AttachmentId, rhs: AttachmentId) {
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
assert(lhsInfo.file.exists())
assert(rhsInfo.file.exists())
assertNotEquals(lhsInfo.file, rhsInfo.file)
}
fun assertDataHashStartMatches(lhs: AttachmentId, rhs: AttachmentId) {
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
assertNotNull(lhsInfo.hashStart)
assertEquals("DATA_HASH_START's did not match!", lhsInfo.hashStart, rhsInfo.hashStart)
}
fun assertDataHashEndMatches(lhs: AttachmentId, rhs: AttachmentId) {
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
assertNotNull(lhsInfo.hashEnd)
assertEquals("DATA_HASH_END's did not match!", lhsInfo.hashEnd, rhsInfo.hashEnd)
}
fun assertDataHashEnd(id: AttachmentId, byteArray: ByteArray) {
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(id)!!
assertArrayEquals(byteArray, Base64.decode(dataFileInfo.hashEnd!!))
}
fun assertRemoteFieldsMatch(lhs: AttachmentId, rhs: AttachmentId) {
val lhsAttachment = SignalDatabase.attachments.getAttachment(lhs)!!
val rhsAttachment = SignalDatabase.attachments.getAttachment(rhs)!!
assertEquals(lhsAttachment.remoteLocation, rhsAttachment.remoteLocation)
assertEquals(lhsAttachment.remoteKey, rhsAttachment.remoteKey)
assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest)
assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest)
assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize)
assertEquals(lhsAttachment.cdnNumber, rhsAttachment.cdnNumber)
}
fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) {
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertEquals(0, databaseAttachment.uploadTimestamp)
assertNull(databaseAttachment.remoteLocation)
assertNull(databaseAttachment.remoteDigest)
assertNull(databaseAttachment.remoteKey)
assertEquals(0, databaseAttachment.cdnNumber)
}
fun assertSkipTransform(attachmentId: AttachmentId, state: Boolean) {
val transformProperties = SignalDatabase.attachments.getTransformProperties(attachmentId)!!
assertEquals("Incorrect skipTransform!", transformProperties.skipTransform, state)
}
private fun ByteArray.asMediaStream(): MediaStream {
return MediaStream(this.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
}
private fun createPointerAttachment(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): PointerAttachment {
val location = "somewhere-${Random.nextLong()}"
val key = "somekey-${Random.nextLong()}"
val digest = Random.nextBytes(32)
val incrementalDigest = Random.nextBytes(16)
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
return PointerAttachment(
"image/jpeg",
AttachmentTable.TRANSFER_PROGRESS_DONE,
databaseAttachment.size, // size
null,
3, // cdnNumber
location,
key,
digest,
incrementalDigest,
5, // incrementalMacChunkSize
null,
databaseAttachment.voiceNote,
databaseAttachment.borderless,
databaseAttachment.videoGif,
databaseAttachment.width,
databaseAttachment.height,
uploadTimestamp,
databaseAttachment.caption,
databaseAttachment.stickerLocator,
databaseAttachment.blurHash
)
}
}
private fun test(content: TestContext.() -> Unit) {
SignalDatabase.attachments.deleteAllAttachments()
val context = TestContext()
context.content()
}
}

View file

@ -52,7 +52,7 @@ class MessageContentProcessor__recipientStatusTest {
processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId = groupId),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)
@ -64,7 +64,7 @@ class MessageContentProcessor__recipientStatusTest {
processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId = groupId),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)

View file

@ -0,0 +1,225 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_readSyncs {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var alice: RecipientId
private lateinit var bob: RecipientId
private lateinit var group: GroupTestingUtils.TestGroupInfo
private lateinit var processor: MessageContentProcessor
@Before
fun setUp() {
alice = harness.others[0]
bob = harness.others[1]
group = harness.group!!
processor = MessageContentProcessor(harness.context)
val threadIdSlot = slot<Long>()
mockkStatic(ThreadUpdateJob::class)
every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers {
SignalDatabase.threads.update(threadIdSlot.captured, false)
}
}
@After
fun tearDown() {
unmockkStatic(ThreadUpdateJob::class)
}
@Test
fun handleSynchronizeReadMessage() {
val messageHelper = MessageHelper()
val message1Timestamp = messageHelper.incomingText().timestamp
val message2Timestamp = messageHelper.incomingText().timestamp
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 2
messageHelper.syncReadMessage(alice to message1Timestamp, alice to message2Timestamp)
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 0
}
@Test
fun handleSynchronizeReadMessageMissingTimestamp() {
val messageHelper = MessageHelper()
messageHelper.incomingText().timestamp
val message2Timestamp = messageHelper.incomingText().timestamp
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 2
messageHelper.syncReadMessage(alice to message2Timestamp)
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 0
}
@Test
fun handleSynchronizeReadWithEdits() {
val messageHelper = MessageHelper()
val message1Timestamp = messageHelper.incomingText().timestamp
messageHelper.syncReadMessage(alice to message1Timestamp)
val editMessage1Timestamp1 = messageHelper.incomingEditText(message1Timestamp).timestamp
val editMessage1Timestamp2 = messageHelper.incomingEditText(editMessage1Timestamp1).timestamp
val message2Timestamp = messageHelper.incomingMedia().timestamp
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 2
messageHelper.syncReadMessage(alice to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2)
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 0
}
@Test
fun handleSynchronizeReadWithEditsInGroup() {
val messageHelper = MessageHelper()
val message1Timestamp = messageHelper.incomingText(sender = alice, destination = group.recipientId).timestamp
messageHelper.syncReadMessage(alice to message1Timestamp)
val editMessage1Timestamp1 = messageHelper.incomingEditText(targetTimestamp = message1Timestamp, sender = alice, destination = group.recipientId).timestamp
val editMessage1Timestamp2 = messageHelper.incomingEditText(targetTimestamp = editMessage1Timestamp1, sender = alice, destination = group.recipientId).timestamp
val message2Timestamp = messageHelper.incomingMedia(sender = bob, destination = group.recipientId).timestamp
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId)!!
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 2
messageHelper.syncReadMessage(bob to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2)
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
threadRecord.unreadCount assertIs 0
}
private inner class MessageHelper(var startTime: Long = System.currentTimeMillis()) {
fun incomingText(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
startTime += 1000
val messageData = MessageData(timestamp = startTime)
processor.process(
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
content = MessageContentFuzzer.fuzzTextMessage(
sentTimestamp = messageData.timestamp,
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
),
metadata = MessageContentFuzzer.envelopeMetadata(
source = sender,
destination = harness.self.id,
groupId = if (destination == group.recipientId) group.groupId else null
),
serverDeliveredTimestamp = messageData.timestamp + 10
)
return messageData
}
fun incomingMedia(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
startTime += 1000
val messageData = MessageData(timestamp = startTime)
processor.process(
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
content = MessageContentFuzzer.fuzzStickerMediaMessage(
sentTimestamp = messageData.timestamp,
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
),
metadata = MessageContentFuzzer.envelopeMetadata(
source = sender,
destination = harness.self.id,
groupId = if (destination == group.recipientId) group.groupId else null
),
serverDeliveredTimestamp = messageData.timestamp + 10
)
return messageData
}
fun incomingEditText(targetTimestamp: Long = System.currentTimeMillis(), sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
startTime += 1000
val messageData = MessageData(timestamp = startTime)
processor.process(
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
content = MessageContentFuzzer.editTextMessage(
targetTimestamp = targetTimestamp,
editedDataMessage = MessageContentFuzzer.fuzzTextMessage(
sentTimestamp = messageData.timestamp,
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
).dataMessage!!
),
metadata = MessageContentFuzzer.envelopeMetadata(
source = sender,
destination = harness.self.id,
groupId = if (destination == group.recipientId) group.groupId else null
),
serverDeliveredTimestamp = messageData.timestamp + 10
)
return messageData
}
fun syncReadMessage(vararg reads: Pair<RecipientId, Long>): MessageData {
startTime += 1000
val messageData = MessageData(timestamp = startTime)
processor.process(
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
content = MessageContentFuzzer.syncReadsMessage(reads.toList()),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2),
serverDeliveredTimestamp = messageData.timestamp + 10
)
return messageData
}
}
private data class MessageData(val serverGuid: UUID = UUID.randomUUID(), val timestamp: Long)
}

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.testing
import okio.ByteString.Companion.toByteString
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
@ -9,6 +10,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.internal.push.GroupContextV2
import kotlin.random.Random
/**
@ -46,5 +48,8 @@ object GroupTestingUtils {
return member(aci = requireAci())
}
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId) {
val groupV2Context: GroupContextV2
get() = GroupContextV2(masterKey = masterKey.serialize().toByteString(), revision = 0)
}
}

View file

@ -12,6 +12,7 @@ import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.BodyRange
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.EditMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.SyncMessage
@ -33,22 +34,22 @@ object MessageContentFuzzer {
/**
* Create an [Envelope].
*/
fun envelope(timestamp: Long): Envelope {
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
return Envelope.Builder()
.timestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuid(UUID.randomUUID().toString())
.serverGuid(serverGuid.toString())
.build()
}
/**
* Create metadata to match an [Envelope].
*/
fun envelopeMetadata(source: RecipientId, destination: RecipientId, groupId: GroupId.V2? = null): EnvelopeMetadata {
fun envelopeMetadata(source: RecipientId, destination: RecipientId, sourceDeviceId: Int = 1, groupId: GroupId.V2? = null): EnvelopeMetadata {
return EnvelopeMetadata(
sourceServiceId = Recipient.resolved(source).requireServiceId(),
sourceE164 = null,
sourceDeviceId = 1,
sourceDeviceId = sourceDeviceId,
sealedSender = true,
groupId = groupId?.decodedId,
destinationServiceId = Recipient.resolved(destination).requireServiceId()
@ -60,10 +61,11 @@ object MessageContentFuzzer {
* - An expire timer value
* - Bold style body ranges
*/
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
fun fuzzTextMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null): Content {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
timestamp = sentTimestamp
body = string()
if (random.nextBoolean()) {
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
@ -87,6 +89,20 @@ object MessageContentFuzzer {
.build()
}
/**
* Create an edit message.
*/
fun editTextMessage(targetTimestamp: Long, editedDataMessage: DataMessage): Content {
return Content.Builder()
.editMessage(
EditMessage.Builder().buildWith {
targetSentTimestamp = targetTimestamp
dataMessage = editedDataMessage
}
)
.build()
}
/**
* Create a sync sent text message for the given [DataMessage].
*/
@ -116,6 +132,24 @@ object MessageContentFuzzer {
).build()
}
/**
* Create a sync reads message for the given [RecipientId] and message timestamp pairings.
*/
fun syncReadsMessage(timestamps: List<Pair<RecipientId, Long>>): Content {
return Content
.Builder()
.syncMessage(
SyncMessage.Builder().buildWith {
read = timestamps.map { (senderId, timestamp) ->
SyncMessage.Read.Builder().buildWith {
this.senderAci = Recipient.resolved(senderId).requireAci().toString()
this.timestamp = timestamp
}
}
}
).build()
}
/**
* Create a random media message that may be:
* - A text body
@ -184,22 +218,21 @@ object MessageContentFuzzer {
}
/**
* Create a random media message that can never contain a text body. It may be:
* - A sticker
* Create a random media message that contains a sticker.
*/
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
fun fuzzStickerMediaMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null): Content {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
if (random.nextFloat() < 0.9) {
sticker = DataMessage.Sticker.Builder().buildWith {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data_ = attachmentPointer()
emoji = emojis.random(random)
}
timestamp = sentTimestamp
sticker = DataMessage.Sticker.Builder().buildWith {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data_ = attachmentPointer()
emoji = emojis.random(random)
}
groupV2 = groupContextV2
}
).build()
}

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic"

View file

@ -681,6 +681,7 @@
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"/>
@ -916,11 +917,20 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.MessageBackupsTestRestoreActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:exported="false"/>
<activity android:name=".profiles.manage.EditProfileActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".nicknames.NicknameActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity
android:name=".payments.preferences.PaymentsActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"

View file

@ -68,6 +68,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
@ -241,6 +242,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
.addPostRender(GroupRingCleanupJob::enqueue)
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
.execute();
Log.d(TAG, "onCreateUnlock() took " + (System.currentTimeMillis() - startTime) + " ms");
@ -345,7 +347,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
public void checkBuildExpiration() {
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
Log.w(TAG, "Build expired!");
SignalStore.misc().markClientDeprecated();
SignalStore.misc().setClientDeprecated(true);
}
}

View file

@ -28,6 +28,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.signal.core.util.Base64;
@ -233,6 +234,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
protected void onPostExecute(Integer result) {
super.onPostExecute(result);
LinkedDeviceInactiveCheckJob.enqueue();
Context context = DeviceActivity.this;
switch (result) {

View file

@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
@ -167,6 +168,7 @@ public class DeviceListFragment extends ListFragment
super.onPostExecute(result);
if (result) {
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
LinkedDeviceInactiveCheckJob.enqueue();
} else {
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
}

View file

@ -124,7 +124,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done, (d, w) -> OldDeviceExitActivity.exit(this))
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device, (d, w) -> {
SignalStore.misc().clearOldDeviceTransferLocked();
SignalStore.misc().setOldDeviceTransferLocked(false);
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork();
})
.setCancelable(false)

View file

@ -139,6 +139,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
public static final String EXTRA_LAUNCH_IN_PIP = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
@ -161,6 +162,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private LifecycleDisposable lifecycleDisposable;
private long lastCallLinkDisconnectDialogShowTime;
private ControlsAndInfoController controlsAndInfo;
private boolean enterPipOnResume;
private long lastProcessedIntentTimestamp;
private Disposable ephemeralStateDisposable = Disposable.empty();
@ -265,6 +268,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}, TimeUnit.SECONDS.toMillis(1));
}
if (enterPipOnResume) {
enterPipOnResume = false;
enterPipModeIfPossible();
}
}
@Override
@ -312,10 +320,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
requestNewSizesThrottle.clear();
}
ApplicationDependencies.getSignalCallManager().setEnableVideo(false);
if (!viewModel.isCallStarting()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
if (state != null) {
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
} else if (state.getCallState().getInOngoingCall() && isInPipMode()) {
ApplicationDependencies.getSignalCallManager().relaunchPipOnForeground();
}
}
}
}
@ -379,6 +393,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.d(TAG, "Intent: Action: " + intent.getAction());
Log.d(TAG, "Intent: EXTRA_STARTED_FROM_FULLSCREEN: " + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false));
Log.d(TAG, "Intent: EXTRA_ENABLE_VIDEO_IF_AVAILABLE: " + intent.getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false));
Log.d(TAG, "Intent: EXTRA_LAUNCH_IN_PIP: " + intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false));
}
private void processIntent(@NonNull Intent intent) {
@ -391,6 +406,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
} else if (END_CALL_ACTION.equals(action)) {
handleEndCall();
}
if (System.currentTimeMillis() - lastProcessedIntentTimestamp > TimeUnit.SECONDS.toMillis(1)) {
enterPipOnResume = intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false);
}
lastProcessedIntentTimestamp = System.currentTimeMillis();
}
private void initializePendingParticipantFragmentListener() {
@ -861,8 +882,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
private boolean isSystemPipEnabledAndAvailable() {
return Build.VERSION.SDK_INT >= 26 &&
getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
return Build.VERSION.SDK_INT >= 26 && getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
}
@RequiresApi(26)

View file

@ -3,13 +3,14 @@ package org.thoughtcrime.securesms.attachments
import android.os.Parcelable
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.parcelize.Parcelize
import org.signal.core.util.DatabaseId
@Parcelize
data class AttachmentId(
@JsonProperty("rowId")
@JvmField
val id: Long
) : Parcelable {
) : Parcelable, DatabaseId {
val isValid: Boolean
get() = id >= 0
@ -17,4 +18,8 @@ data class AttachmentId(
override fun toString(): String {
return "AttachmentId::$id"
}
override fun serialize(): String {
return id.toString()
}
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.attachments
import android.net.Uri
import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.core.util.Base64.encodeWithPadding
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
@ -14,7 +15,8 @@ import org.whispersystems.signalservice.internal.push.DataMessage
import java.util.Optional
class PointerAttachment : Attachment {
private constructor(
@VisibleForTesting
constructor(
contentType: String,
transferState: Int,
size: Long,

View file

@ -198,7 +198,7 @@ public class FullBackupImporter extends FullBackupBase {
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
throws IOException
{
File dataFile = AttachmentTable.newFile(context);
File dataFile = AttachmentTable.newDataFile(context);
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
boolean isLegacyTable = SqlUtil.tableExists(db, "part");

View file

@ -70,13 +70,13 @@ object BackupRepository {
)
}
val exportState = ExportState()
val exportState = ExportState(System.currentTimeMillis())
writer.use {
writer.write(
BackupInfo(
version = VERSION,
backupTimeMs = System.currentTimeMillis()
backupTimeMs = exportState.backupTime
)
)
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
@ -118,7 +118,7 @@ object BackupRepository {
val masterKey = SignalStore.svr().getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
return MessageBackup.validate(key, inputStreamFactory, length)
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
}
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
@ -396,7 +396,7 @@ object BackupRepository {
}
}
class ExportState {
class ExportState(val backupTime: Long) {
val recipientIds = HashSet<Long>()
val threadIds = HashSet<Long>()
}

View file

@ -17,12 +17,15 @@ import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
@ -106,6 +109,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 attachmentsById: Map<Long, List<DatabaseAttachment>> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys)
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
for ((id, record) in records) {
@ -117,14 +121,23 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
MessageTypes.isChangeNumber(record.type) -> {
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
builder.sms = false
}
MessageTypes.isBoostRequest(record.type) -> {
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
builder.sms = false
}
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
MessageTypes.isExpirationTimerUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate((record.expiresIn / 1000).toInt()))
MessageTypes.isExpirationTimerUpdate(record.type) -> {
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn.toInt()))
builder.expiresInMs = null
}
MessageTypes.isProfileChange(record.type) -> {
builder.updateMessage = ChatUpdateMessage(
profileChange = try {
@ -140,6 +153,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
ProfileChangeChatUpdate()
}
)
builder.sms = false
}
MessageTypes.isSessionSwitchoverType(record.type) -> {
builder.updateMessage = ChatUpdateMessage(
@ -188,10 +202,10 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
} else {
when {
MessageTypes.isMissedAudioCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL)))
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL)))
}
MessageTypes.isMissedVideoCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL)))
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL)))
}
MessageTypes.isIncomingAudioCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL)))
@ -230,11 +244,11 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
}
record.body == null -> {
Log.w(TAG, "Record missing a body, skipping")
record.body == null && !attachmentsById.containsKey(record.id) -> {
Log.w(TAG, "Record missing a body and doesnt have attachments, skipping")
continue
}
else -> builder.standardMessage = record.toTextMessage(reactionsById[id], mentions = mentionsById[id])
else -> builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
}
buffer += builder.build()
@ -268,7 +282,6 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
chatId = record.threadId
authorId = record.fromRecipientId
dateSent = record.dateSent
sealedSender = record.sealedSender
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
revisions = emptyList()
@ -282,19 +295,28 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
incoming = ChatItem.IncomingMessageDetails(
dateServerSent = record.dateServer,
dateReceived = record.dateReceived,
read = record.read
read = record.read,
sealedSender = record.sealedSender
)
}
}
}
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?): StandardMessage {
return StandardMessage(
quote = this.toQuote(),
text = Text(
body = this.body!!,
private fun BackupMessageRecord.toStandardMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
val text = if (body == null) {
null
} else {
Text(
body = this.body,
bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList())
),
)
}
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
val messageAttachments = attachments?.filter { !it.quote } ?: emptyList()
return StandardMessage(
quote = this.toQuote(quotedAttachments),
text = text,
attachments = messageAttachments.toBackupAttachments(),
// TODO Link previews!
linkPreview = emptyList(),
longText = null,
@ -302,14 +324,14 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
)
}
private fun BackupMessageRecord.toQuote(): Quote? {
private fun BackupMessageRecord.toQuote(attachments: List<DatabaseAttachment>? = null): Quote? {
return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) {
// TODO Attachments!
val type = QuoteModel.Type.fromCode(this.quoteType)
Quote(
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
authorId = this.quoteAuthor,
text = this.quoteBody,
attachments = attachments?.toBackupQuoteAttachments() ?: emptyList(),
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
type = when (type) {
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
@ -321,6 +343,44 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun List<DatabaseAttachment>.toBackupQuoteAttachments(): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
contentType = attachment.contentType,
fileName = attachment.fileName,
thumbnail = attachment.toBackupAttachment()
)
}
}
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
return MessageAttachment(
pointer = FilePointer(
attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = this.remoteLocation ?: "",
cdnNumber = this.cdnNumber,
uploadTimestamp = this.uploadTimestamp
),
key = if (remoteKey != null) decode(remoteKey).toByteString() else null,
contentType = this.contentType,
size = this.size.toInt(),
incrementalMac = this.incrementalDigest?.toByteString(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
fileName = this.fileName,
width = this.width,
height = this.height,
caption = this.caption,
blurHash = this.blurHash?.hash
)
)
}
private fun List<DatabaseAttachment>.toBackupAttachments(): List<MessageAttachment> {
return this.map { attachment ->
attachment.toBackupAttachment()
}
}
private fun List<Mention>.toBackupBodyRanges(): List<BackupBodyRange> {
return this.map {
BackupBodyRange(

View file

@ -10,13 +10,17 @@ import androidx.core.content.contentValuesOf
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
@ -44,8 +48,12 @@ 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.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
/**
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
@ -228,6 +236,17 @@ class ChatItemImportInserter(
}
}
}
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
attachment.toLocalAttachment()
}
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
it.toLocalAttachment()
} ?: emptyList()
if (attachments.isNotEmpty()) {
followUp = { messageRowId ->
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments, quoteAttachments)
}
}
}
return MessageInsert(contentValues, followUp)
}
@ -244,7 +263,7 @@ class ChatItemImportInserter(
contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize())
contentValues.put(MessageTable.THREAD_ID, threadId)
contentValues.put(MessageTable.DATE_RECEIVED, this.incoming?.dateReceived ?: this.dateSent)
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.lastStatusUpdateTimestamp } ?: 0)
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOfOrNull { it.lastStatusUpdateTimestamp } ?: 0)
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
contentValues.put(MessageTable.REVISION_NUMBER, 0)
@ -268,7 +287,7 @@ class ChatItemImportInserter(
contentValues.put(MessageTable.VIEWED_COLUMN, 0)
contentValues.put(MessageTable.HAS_READ_RECEIPT, 0)
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
contentValues.put(MessageTable.UNIDENTIFIED, this.incoming?.sealedSender?.toInt() ?: 0)
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
contentValues.put(MessageTable.NOTIFIED, 1)
}
@ -382,23 +401,24 @@ class ChatItemImportInserter(
var typeFlags: Long = 0
when {
updateMessage.simpleUpdate != null -> {
val typeWithoutBase = (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
typeFlags = when (updateMessage.simpleUpdate.type) {
SimpleChatUpdate.Type.UNKNOWN -> 0
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
SimpleChatUpdate.Type.UNKNOWN -> typeWithoutBase
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT or typeWithoutBase
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE or typeWithoutBase
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED or typeWithoutBase
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
}
}
updateMessage.expirationTimerChange != null -> {
typeFlags = MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresInMs.toLong())
}
updateMessage.profileChange != null -> {
@ -408,12 +428,12 @@ class ChatItemImportInserter(
put(MessageTable.BODY, Base64.encodeWithPadding(profileChangeDetails))
}
updateMessage.sessionSwitchover != null -> {
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
put(MessageTable.BODY, Base64.encodeWithPadding(sessionSwitchoverDetails))
}
updateMessage.threadMerge != null -> {
typeFlags = MessageTypes.THREAD_MERGE_TYPE
typeFlags = MessageTypes.THREAD_MERGE_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
val threadMergeDetails = ThreadMergeEvent(previousE164 = updateMessage.threadMerge.previousE164.toString()).encode()
put(MessageTable.BODY, Base64.encodeWithPadding(threadMergeDetails))
}
@ -428,8 +448,10 @@ class ChatItemImportInserter(
IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL -> MessageTypes.INCOMING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags
}
}
@ -446,10 +468,10 @@ class ChatItemImportInserter(
GV2UpdateDescription(groupChangeUpdate = updateMessage.groupChange)
).encode()
)
typeFlags = MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT
}
}
this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags)
this.put(MessageTable.TYPE, typeFlags)
}
private fun ContentValues.addQuote(quote: Quote) {
@ -508,7 +530,7 @@ class ChatItemImportInserter(
}
return BodyRangeList(
ranges = this.map { bodyRange ->
ranges = this.filter { it.mentionAci == null }.map { bodyRange ->
BodyRangeList.BodyRange(
mentionUuid = bodyRange.mentionAci?.let { UuidUtil.fromByteString(it) }?.toString(),
style = bodyRange.style?.let {
@ -541,6 +563,39 @@ class ChatItemImportInserter(
}
}
private fun MessageAttachment.toLocalAttachment(contentType: String? = pointer?.contentType, fileName: String? = pointer?.fileName): Attachment? {
if (pointer == null) return null
if (pointer.attachmentLocator != null) {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
pointer.attachmentLocator.cdnNumber,
SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey),
contentType,
pointer.key?.toByteArray(),
Optional.ofNullable(pointer.size),
Optional.empty(),
pointer.width ?: 0,
pointer.height ?: 0,
Optional.empty(),
Optional.ofNullable(pointer.incrementalMac?.toByteArray()),
pointer.incrementalMacChunkSize ?: 0,
Optional.ofNullable(fileName),
flag == MessageAttachment.Flag.VOICE_MESSAGE,
flag == MessageAttachment.Flag.BORDERLESS,
flag == MessageAttachment.Flag.GIF,
Optional.ofNullable(pointer.caption),
Optional.ofNullable(pointer.blurHash),
pointer.attachmentLocator.uploadTimestamp
)
return PointerAttachment.forPointer(Optional.of(signalAttachmentPointer)).orNull()
}
return null
}
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
?: if (this.contentType == null) null else PointerAttachment.forPointer(SignalServiceDataMessage.Quote.QuotedAttachment(contentType = this.contentType!!, fileName = this.fileName, thumbnail = null)).orNull()
}
private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?)
private class Buffer(

View file

@ -11,11 +11,12 @@ import org.signal.core.util.select
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(MessageTable::class.java)
private const val BASE_TYPE = "base_type"
fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
fun MessageTable.getMessagesForBackup(backupTime: Long): ChatItemExportIterator {
val cursor = readableDatabase
.select(
MessageTable.ID,
@ -53,13 +54,11 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
.from(MessageTable.TABLE_NAME)
.where(
"""
$BASE_TYPE IN (
${MessageTypes.BASE_INBOX_TYPE},
${MessageTypes.BASE_OUTBOX_TYPE},
${MessageTypes.BASE_SENT_TYPE},
${MessageTypes.BASE_SENDING_TYPE},
${MessageTypes.BASE_SENT_FAILED_TYPE}
) OR ${MessageTable.IS_CALL_TYPE_CLAUSE}
(
${MessageTable.EXPIRE_STARTED} = 0
OR
(${MessageTable.EXPIRES_IN} > 0 AND (${MessageTable.EXPIRE_STARTED} + ${MessageTable.EXPIRES_IN}) > $backupTime + ${TimeUnit.DAYS.toMillis(1)})
)
"""
)
.orderBy("${MessageTable.DATE_RECEIVED} ASC")

View file

@ -19,7 +19,7 @@ object ChatItemBackupProcessor {
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.messages.getMessagesForBackup().use { chatItems ->
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime).use { chatItems ->
for (chatItem in chatItems) {
if (exportState.threadIds.contains(chatItem.chatId)) {
emitter.emit(Frame(chatItem = chatItem))

View file

@ -0,0 +1,148 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
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.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationUtil
class MessageBackupsTestRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context): Intent {
return Intent(context, MessageBackupsTestRestoreActivity::class.java)
}
}
private val viewModel: MessageBackupsTestRestoreViewModel by viewModels()
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
private fun onPlaintextClicked() {
viewModel.onPlaintextToggled()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
contentResolver.getLength(uri)?.let { length ->
viewModel.import(length) { contentResolver.openInputStream(uri)!! }
}
} ?: Toast.makeText(this, "No URI selected", Toast.LENGTH_SHORT).show()
}
}
setContent {
val state by viewModel.state
Surface {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
StateLabel(text = "Plaintext?")
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = state.plaintext,
onCheckedChange = { onPlaintextClicked() }
)
}
Spacer(modifier = Modifier.height(8.dp))
Buttons.LargePrimary(
onClick = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
importFileLauncher.launch(intent)
},
enabled = !state.importState.inProgress
) {
Text("Import from file")
}
Spacer(modifier = Modifier.height(8.dp))
Dividers.Default()
Buttons.LargeTonal(
onClick = { continueRegistration() },
enabled = !state.importState.inProgress
) {
Text("Continue Reg Flow")
}
}
}
}
}
private fun continueRegistration() {
if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
val main = MainActivity.clearTop(this)
val profile = CreateProfileActivity.getIntentForUserProfile(this)
profile.putExtra("next_intent", main)
startActivity(profile)
} else {
RegistrationUtil.maybeMarkRegistrationComplete()
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
startActivity(MainActivity.clearTop(this))
}
finish()
}
@Composable
private fun StateLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.recipients.Recipient
import java.io.InputStream
class MessageBackupsTestRestoreViewModel : ViewModel() {
val disposables = CompositeDisposable()
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(importState = ImportState.NONE, plaintext = false))
val state: State<ScreenState> = _state
fun import(length: Long, inputStreamFactory: () -> InputStream) {
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = _state.value.plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(importState = ImportState.NONE)
}
}
fun onPlaintextToggled() {
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
}
override fun onCleared() {
disposables.clear()
}
data class ScreenState(
val importState: ImportState,
val plaintext: Boolean
)
enum class ImportState(val inProgress: Boolean = false) {
NONE, IN_PROGRESS(true)
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
/**
* Represents a "Feature" included for a specify tier of message backups
*/
data class MessageBackupsTypeFeature(
val iconResourceId: Int,
val label: String
)
/**
* Renders a "feature row" for a given feature.
*/
@Composable
fun MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature: MessageBackupsTypeFeature,
iconTint: Color = LocalContentColor.current,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = messageBackupsTypeFeature.iconResourceId),
contentDescription = null,
tint = iconTint,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = messageBackupsTypeFeature.label,
style = MaterialTheme.typography.bodyLarge
)
}
}

View file

@ -10,7 +10,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -19,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -271,32 +269,8 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
}
}
@Composable
private fun MessageBackupsTypeFeatureRow(messageBackupsTypeFeature: MessageBackupsTypeFeature) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = messageBackupsTypeFeature.iconResourceId),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = messageBackupsTypeFeature.label,
style = MaterialTheme.typography.bodyLarge
)
}
}
data class MessageBackupsType(
val pricePerMonth: FiatMoney,
val title: String,
val features: ImmutableList<MessageBackupsTypeFeature>
)
data class MessageBackupsTypeFeature(
val iconResourceId: Int,
val label: String
)

View file

@ -0,0 +1,179 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.restore
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.devicetransfer.moreoptions.MoreTransferOrRestoreOptionsMode
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment which facilitates restoring from a backup during
* registration.
*/
class RestoreFromBackupFragment : ComposeFragment() {
private val navArgs: RestoreFromBackupFragmentArgs by navArgs()
@Composable
override fun FragmentContent() {
RestoreFromBackupContent(
features = persistentListOf(),
onRestoreBackupClick = {
// TODO [message-backups] Restore backup.
},
onCancelClick = {
findNavController()
.popBackStack()
},
onMoreOptionsClick = {
findNavController()
.safeNavigate(RestoreFromBackupFragmentDirections.actionRestoreFromBacakupFragmentToMoreOptions(MoreTransferOrRestoreOptionsMode.SELECTION))
},
cancelable = navArgs.cancelable
)
}
}
@Preview
@Composable
private fun RestoreFromBackupContentPreview() {
Previews.Preview {
RestoreFromBackupContent(
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Your last 30 days of media"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = "All of your text messages"
)
),
onRestoreBackupClick = {},
onCancelClick = {},
onMoreOptionsClick = {},
true
)
}
}
@Composable
private fun RestoreFromBackupContent(
features: ImmutableList<MessageBackupsTypeFeature>,
onRestoreBackupClick: () -> Unit,
onCancelClick: () -> Unit,
onMoreOptionsClick: () -> Unit,
cancelable: Boolean
) {
Column(
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 40.dp, bottom = 24.dp)
) {
Text(
text = "Restore from backup", // TODO [message-backups] Finalized copy.
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
val yourLastBackupText = buildAnnotatedString {
append("Your last backup was made on March 5, 2024 at 9:00am.") // TODO [message-backups] Finalized copy.
append(" ")
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append("Only media sent or received in the past 30 days is included.") // TODO [message-backups] Finalized copy.
}
}
Text(
text = yourLastBackupText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 28.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 18.dp)
) {
Text(
text = "Your backup includes:", // TODO [message-backups] Finalized copy.
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 6.dp)
)
features.forEach {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = it,
iconTint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Restore backup" // TODO [message-backups] Finalized copy.
)
}
if (cancelable) {
TextButton(
onClick = onCancelClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = android.R.string.cancel)
)
}
} else {
TextButton(
onClick = onMoreOptionsClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
)
}
}
}
}

View file

@ -182,7 +182,7 @@ class CallLogAdapter(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient) -> Unit
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
@ -231,7 +231,7 @@ class CallLogAdapter(
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient)
onStartVideoCallClicked(model.callLink.recipient, true)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
@ -243,7 +243,7 @@ class CallLogAdapter(
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
private val onStartAudioCallClicked: (Recipient) -> Unit,
private val onStartVideoCallClicked: (Recipient) -> Unit
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
@ -333,7 +333,7 @@ class CallLogAdapter(
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
@ -341,8 +341,8 @@ class CallLogAdapter(
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, model.call.canUserBeginCall) }
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer, model.call.canUserBeginCall) }
when (model.call.groupCallState) {
CallLogRow.GroupCallState.NONE, CallLogRow.GroupCallState.FULL -> {
@ -472,6 +472,6 @@ class CallLogAdapter(
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(recipient: Recipient)
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
}
}

View file

@ -20,6 +20,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionInflater
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -32,6 +33,7 @@ import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@ -45,6 +47,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
@ -123,6 +126,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val callLogAdapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
callLogAdapter.setPagingController(viewModel.controller)
callLogAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
(requireActivity() as? MainActivity)?.onFirstRender()
callLogAdapter.unregisterAdapterDataObserver(this)
}
})
val scrollToPositionDelegate = ScrollToPositionDelegate(
recyclerView = binding.recycler,
@ -376,8 +385,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
CommunicationActions.startVoiceCall(this, recipient)
}
override fun onStartVideoCallClicked(recipient: Recipient) {
CommunicationActions.startVideoCall(this, recipient)
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
if (canUserBeginCall) {
CommunicationActions.startVideoCall(this, recipient)
} else {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
}
}
override fun startSelection(call: CallLogRow) {

View file

@ -43,8 +43,9 @@ class CallLogRepository(
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@execute
SignalDatabase.calls.markAllCallEventsRead()
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsRead(System.currentTimeMillis()))
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsRead(latestCall))
}
}
@ -95,10 +96,10 @@ class CallLogRepository(
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
return Single.fromCallable {
SignalDatabase.rawDatabase.withinTransaction {
val latestTimestamp = SignalDatabase.calls.getLatestTimestamp()
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestTimestamp)
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestTimestamp)
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestTimestamp))
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@withinTransaction
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestCall.timestamp)
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestCall.timestamp)
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestCall))
}
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())

View file

@ -41,6 +41,7 @@ sealed class CallLogRow {
val children: Set<Long>,
val searchQuery: String?,
val callLinkPeekInfo: CallLinkPeekInfo?,
val canUserBeginCall: Boolean,
override val id: Id = Id.Call(children)
) : CallLogRow()

View file

@ -130,9 +130,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
if (previousKeyboardHeight != keyboardInsets.bottom) {
keyboardStateListeners.forEach {
if (previousKeyboardHeight <= 0) {
if (previousKeyboardHeight <= 0 && keyboardInsets.bottom > 0) {
it.onKeyboardShown()
} else {
} else if (previousKeyboardHeight > 0 && keyboardInsets.bottom <= 0) {
it.onKeyboardHidden()
}
}

View file

@ -1,25 +0,0 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
public class PushRegistrationReminder extends Reminder {
public PushRegistrationReminder(final Context context) {
super(R.string.reminder_header_push_title, R.string.reminder_header_push_text);
setOkListener(v -> context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context)));
}
@Override
public boolean isDismissable() {
return false;
}
public static boolean isEligible() {
return !SignalStore.account().isRegistered();
}
}

View file

@ -5,6 +5,7 @@ import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@ -26,6 +27,6 @@ public class UnauthorizedReminder extends Reminder {
}
public static boolean isEligible(Context context) {
return TextSecurePreferences.isUnauthorizedReceived(context);
return TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account().isRegistered();
}
}

View file

@ -152,7 +152,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
},
isEnabled = state.isDeprecatedOrUnregistered()
isEnabled = state.isRegisteredAndUpToDate()
)
externalLinkPref(
@ -177,7 +177,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
},
isEnabled = state.isDeprecatedOrUnregistered()
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
@ -186,7 +186,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
},
isEnabled = state.isDeprecatedOrUnregistered()
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
@ -195,7 +195,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
},
isEnabled = state.isDeprecatedOrUnregistered()
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
@ -204,7 +204,7 @@ class AppSettingsFragment : DSLSettingsFragment(
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
},
isEnabled = state.isDeprecatedOrUnregistered()
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(

View file

@ -8,7 +8,7 @@ data class AppSettingsState(
val userUnregistered: Boolean,
val clientDeprecated: Boolean
) {
fun isDeprecatedOrUnregistered(): Boolean {
return !(userUnregistered || clientDeprecated)
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated
}
}

View file

@ -16,7 +16,7 @@ class AppSettingsViewModel : ViewModel() {
AppSettingsState(
Recipient.self(),
0,
TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()) || !SignalStore.account().isRegistered,
SignalStore.misc().isClientDeprecated
)
)
@ -37,6 +37,11 @@ class AppSettingsViewModel : ViewModel() {
}
fun refreshDeprecatedOrUnregistered() {
store.update { it.copy(clientDeprecated = SignalStore.misc().isClientDeprecated, userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())) }
store.update {
it.copy(
clientDeprecated = SignalStore.misc().isClientDeprecated,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()) || !SignalStore.account().isRegistered
)
}
}
}

View file

@ -291,7 +291,7 @@ class ChangeNumberRepository(
true
)
SignalStore.misc().setPniInitializedDevices(true)
SignalStore.misc().hasPniInitializedDevices = true
ApplicationDependencies.getGroupsV2Authorization().clear()
}

View file

@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.AppUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.privacy.advanced.AdvancedPrivacySettingsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
@ -137,6 +139,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Unregister"),
summary = DSLSettingsText.from("This will unregister your account without deleting it."),
onClick = {
onUnregisterClicked()
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))
@ -714,6 +724,32 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
}
private fun onUnregisterClicked() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Unregister?")
.setMessage("Are you sure? You'll have to re-register to use Signal again -- no promises that the process will go smoothly.")
.setPositiveButton(android.R.string.ok) { _, _ ->
AdvancedPrivacySettingsRepository(requireContext()).disablePushMessages {
ThreadUtil.runOnMain {
when (it) {
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
SignalStore.account().setRegistered(false)
SignalStore.registrationValues().clearRegistrationComplete()
SignalStore.registrationValues().clearHasUploadedProfile()
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
}
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
Toast.makeText(context, "Network error!", Toast.LENGTH_SHORT).show()
}
}
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun copyPaymentsDataToClipboard() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(

View file

@ -61,7 +61,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
}
fun resetPnpInitializedState() {
SignalStore.misc().setPniInitializedDevices(false)
SignalStore.misc().hasPniInitializedDevices = false
refresh()
}
@ -140,7 +140,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
delayResends = SignalStore.internalValues().delayResends(),
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
pnpInitialized = SignalStore.misc().hasPniInitializedDevices,
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media(),
)

View file

@ -6,15 +6,11 @@ import android.content.Intent
import android.content.IntentFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.text.SpannableStringBuilder
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@ -23,7 +19,6 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SecurePreferenceManager
import org.thoughtcrime.securesms.util.SpanUtil
@ -107,40 +102,6 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
private fun getConfiguration(state: AdvancedPrivacySettingsState): DSLConfiguration {
return configure {
switchPref(
title = DSLSettingsText.from(R.string.preferences__signal_messages_and_calls),
summary = DSLSettingsText.from(getPushToggleSummary(state.isPushEnabled)),
isChecked = state.isPushEnabled
) {
if (state.isPushEnabled) {
val builder = MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls_by_unregistering)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(
android.R.string.ok
) { _, _ -> viewModel.disablePushMessages() }
}
val icon: Drawable = requireNotNull(ContextCompat.getDrawable(builder.context, R.drawable.symbol_info_24))
icon.setBounds(0, 0, ViewUtil.dpToPx(32), ViewUtil.dpToPx(32))
val title = TextView(builder.context)
val padding = ViewUtil.dpToPx(16)
title.setText(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls)
title.setPadding(padding, padding, padding, padding)
title.compoundDrawablePadding = padding / 2
TextViewCompat.setTextAppearance(title, R.style.TextAppearance_Signal_Title2_MaterialDialog)
TextViewCompat.setCompoundDrawablesRelative(title, icon, null, null, null)
builder
.setCustomTitle(title)
.setOnDismissListener { viewModel.refresh() }
.show()
} else {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
}
switchPref(
title = DSLSettingsText.from(R.string.preferences_advanced__always_relay_calls),
summary = DSLSettingsText.from(R.string.preferences_advanced__relay_all_calls_through_the_signal_server_to_avoid_revealing_your_ip_address),

View file

@ -38,25 +38,6 @@ class AdvancedPrivacySettingsViewModel(
)
}
fun disablePushMessages() {
store.update { getState().copy(showProgressSpinner = true) }
repository.disablePushMessages {
when (it) {
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
SignalStore.account().setRegistered(false)
SignalStore.registrationValues().clearRegistrationComplete()
SignalStore.registrationValues().clearHasUploadedProfile()
}
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
singleEvents.postValue(Event.DISABLE_PUSH_FAILED)
}
}
store.update { getState().copy(showProgressSpinner = false) }
}
}
fun setAlwaysRelayCalls(enabled: Boolean) {
sharedPreferences.edit().putBoolean(TextSecurePreferences.ALWAYS_RELAY_CALLS_PREF, enabled).apply()
refresh()

View file

@ -14,6 +14,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
@ -76,6 +77,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreB
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
@ -92,6 +94,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -120,10 +123,6 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
}
private val unblockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24)
}
private val leaveIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_leave_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
@ -153,6 +152,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View
private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate
private lateinit var nicknameLauncher: ActivityResultLauncher<NicknameActivity.Args>
private val navController get() = Navigation.findNavController(requireView())
private val lifecycleDisposable = LifecycleDisposable()
@ -220,6 +220,10 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: MappingAdapter) {
nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) {
// Intentionally left blank
}
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
BioTextPreference.register(adapter)
@ -467,9 +471,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan))
val icon = if (state.disappearingMessagesLifespan <= 0 || state.recipient.isBlocked) {
R.drawable.ic_update_timer_disabled_16
R.drawable.symbol_timer_slash_24
} else {
R.drawable.ic_update_timer_16
R.drawable.symbol_timer_24
}
var enabled = !state.recipient.isBlocked
@ -494,10 +498,25 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
if (FeatureFlags.nicknames() && state.recipient.isIndividual && !state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.NicknameActivity__nickname),
icon = DSLSettingsIcon.from(R.drawable.symbol_edit_24),
onClick = {
nicknameLauncher.launch(
NicknameActivity.Args(
state.recipient.id,
false
)
)
}
)
}
if (!state.recipient.isReleaseNotes) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_color_24),
onClick = {
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
}
@ -507,7 +526,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
if (!state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
@ -552,7 +571,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
if (!state.recipient.isReleaseNotes && !state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
icon = DSLSettingsIcon.from(R.drawable.symbol_safety_number_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
@ -779,11 +798,10 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
val titleTint = if (isBlocked) null else if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
clickPref(
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
icon = DSLSettingsIcon.from(blockUnblockIcon),
icon = if (isBlocked) DSLSettingsIcon.from(R.drawable.symbol_block_24) else DSLSettingsIcon.from(blockIcon),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
if (state.recipient.isBlocked) {

View file

@ -61,6 +61,7 @@ public class CallParticipantView extends ConstraintLayout {
private boolean infoMode;
private boolean raiseHandAllowed;
private Runnable missingMediaKeysUpdater;
private boolean shouldRenderInPip;
private SelfPipMode selfPipMode = SelfPipMode.NOT_SELF_PIP;
@ -198,6 +199,8 @@ public class CallParticipantView extends ConstraintLayout {
pipBadge.setBadgeFromRecipient(participant.getRecipient());
contactPhoto = participant.getRecipient().getContactPhoto();
}
setRenderInPip(shouldRenderInPip);
}
private boolean isMissingMediaKeys(@NonNull CallParticipant participant) {
@ -223,10 +226,15 @@ public class CallParticipantView extends ConstraintLayout {
}
void setRenderInPip(boolean shouldRenderInPip) {
this.shouldRenderInPip = shouldRenderInPip;
if (infoMode) {
infoMessage.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
infoMoreInfo.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
infoOverlay.setOnClickListener(shouldRenderInPip ? v -> infoMoreInfo.performClick() : null);
return;
} else {
infoOverlay.setOnClickListener(null);
}
avatar.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE);
@ -243,7 +251,7 @@ public class CallParticipantView extends ConstraintLayout {
* Adjust UI elements for the various self PIP positions. If called after a {@link TransitionManager#beginDelayedTransition(ViewGroup, Transition)},
* the changes to the UI elements will animate.
*/
void setSelfPipMode(@NonNull SelfPipMode selfPipMode) {
void setSelfPipMode(@NonNull SelfPipMode selfPipMode, boolean isMoreThanOneCameraAvailable) {
Preconditions.checkArgument(selfPipMode != SelfPipMode.NOT_SELF_PIP);
if (this.selfPipMode == selfPipMode) {
@ -274,26 +282,30 @@ public class CallParticipantView extends ConstraintLayout {
ViewUtil.dpToPx(6)
);
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(6)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(6)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
if (isMoreThanOneCameraAvailable) {
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(6)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(6)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(28));
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(16);
switchCameraIcon.setLayoutParams(params);
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(16);
switchCameraIcon.setLayoutParams(params);
switchCameraIconFrame.setClickable(false);
switchCameraIconFrame.setEnabled(false);
switchCameraIconFrame.setClickable(false);
switchCameraIconFrame.setEnabled(false);
} else {
constraints.setVisibility(R.id.call_participant_switch_camera, View.GONE);
}
}
case EXPANDED_SELF_PIP -> {
constraints.connect(
@ -313,26 +325,30 @@ public class CallParticipantView extends ConstraintLayout {
ViewUtil.dpToPx(8)
);
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(8)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(8)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
if (isMoreThanOneCameraAvailable) {
constraints.setVisibility(R.id.call_participant_switch_camera, View.VISIBLE);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.END,
ViewUtil.dpToPx(8)
);
constraints.setMargin(
R.id.call_participant_switch_camera,
ConstraintSet.BOTTOM,
ViewUtil.dpToPx(8)
);
constraints.constrainWidth(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
constraints.constrainHeight(R.id.call_participant_switch_camera, ViewUtil.dpToPx(48));
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(24);
switchCameraIcon.setLayoutParams(params);
ViewGroup.LayoutParams params = switchCameraIcon.getLayoutParams();
params.width = params.height = ViewUtil.dpToPx(24);
switchCameraIcon.setLayoutParams(params);
switchCameraIconFrame.setClickable(true);
switchCameraIconFrame.setEnabled(true);
switchCameraIconFrame.setClickable(true);
switchCameraIconFrame.setEnabled(true);
} else {
constraints.setVisibility(R.id.call_participant_switch_camera, View.GONE);
}
}
case MINI_SELF_PIP -> {
constraints.connect(

View file

@ -16,7 +16,6 @@ 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;
@ -25,10 +24,9 @@ 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 int HORIZONTAL_PARTICIPANTS_CONTAINER_HEIGHT = (int) DimensionUnit.DP.toPixels(36);
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 final ViewGroup parent;
private final View child;
@ -36,7 +34,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;
@ -47,10 +45,9 @@ 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 boolean displayBelowVerticalBoundary = false;
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
private int previousTopBoundary = -1;
private int previousBottomBoundary = -1;
@SuppressLint("ClickableViewAccessibility")
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
@ -135,27 +132,9 @@ 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);
@ -322,7 +301,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
* User drag is implemented by translating the view from the current gravity anchor (corner). When the user drags
* to a new corner, we need to adjust the translations for the new corner so the animation of translation X/Y to 0
* works correctly.
* <p>
*
* For example, if in bottom right and need to move to top right, we need to calculate a new translation Y since instead
* of being translated up from bottom it's translated down from the top.
*/

View file

@ -96,7 +96,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private RecipientId recipientId;
private ImageView answer;
private TextView answerWithoutVideoLabel;
private ImageView cameraDirectionToggle;
private AccessibleToggleButton ringToggle;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView overflow;
@ -178,7 +177,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status);
answer = findViewById(R.id.call_screen_answer_call);
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
overflow = findViewById(R.id.call_screen_overflow_button);
hangup = findViewById(R.id.call_screen_end_call);
@ -273,7 +271,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated()));
});
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
smallLocalRender.findViewById(R.id.call_participant_switch_camera).setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
overflow.setOnClickListener(v -> {
@ -355,7 +352,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
rotatableControls.add(audioToggle);
rotatableControls.add(micToggle);
rotatableControls.add(videoToggle);
rotatableControls.add(cameraDirectionToggle);
rotatableControls.add(decline);
rotatableControls.add(smallLocalAudioIndicator);
rotatableControls.add(ringToggle);
@ -510,19 +506,17 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
if (state == WebRtcLocalRenderState.EXPANDED) {
pictureInPictureExpansionHelper.beginExpandTransition();
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP);
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP, localCallParticipant.isMoreThanOneCameraAvailable());
return;
} else if ((state.isAnySmall() || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) {
pictureInPictureExpansionHelper.beginShrinkTransition();
smallLocalRender.setSelfPipMode(pictureInPictureExpansionHelper.isMiniSize() ? CallParticipantView.SelfPipMode.MINI_SELF_PIP : CallParticipantView.SelfPipMode.NORMAL_SELF_PIP);
smallLocalRender.setSelfPipMode(pictureInPictureExpansionHelper.isMiniSize() ? CallParticipantView.SelfPipMode.MINI_SELF_PIP : CallParticipantView.SelfPipMode.NORMAL_SELF_PIP, localCallParticipant.isMoreThanOneCameraAvailable());
if (state != WebRtcLocalRenderState.GONE) {
return;
}
}
pictureInPictureGestureHelper.setDisplayBelowVerticalBoundary(false);
switch (state) {
case GONE:
largeLocalRender.attachBroadcastVideoSink(null);
@ -532,18 +526,17 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
break;
case SMALL_RECTANGLE:
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToLargeRectangle(displaySmallSelfPipInLandscape);
animatePipToLargeRectangle(displaySmallSelfPipInLandscape, localCallParticipant.isMoreThanOneCameraAvailable());
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
break;
case SMALLER_RECTANGLE:
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToSmallRectangle();
animatePipToSmallRectangle(localCallParticipant.isMoreThanOneCameraAvailable());
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
pictureInPictureGestureHelper.setDisplayBelowVerticalBoundary(true);
break;
case LARGE:
largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink());
@ -792,7 +785,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
}
private void animatePipToLargeRectangle(boolean isLandscape) {
private void animatePipToLargeRectangle(boolean isLandscape, boolean moreThanOneCameraAvailable) {
final Point dimens;
if (isLandscape) {
dimens = new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.NORMAL_PIP_HEIGHT_DP),
@ -809,10 +802,10 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
});
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.NORMAL_SELF_PIP);
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.NORMAL_SELF_PIP, moreThanOneCameraAvailable);
}
private void animatePipToSmallRectangle() {
private void animatePipToSmallRectangle(boolean moreThanOneCameraAvailable) {
pictureInPictureExpansionHelper.startDefaultSizeTransition(new Point(ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_WIDTH_DP),
ViewUtil.dpToPx(PictureInPictureExpansionHelper.MINI_PIP_HEIGHT_DP)),
new PictureInPictureExpansionHelper.Callback() {
@ -822,7 +815,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
});
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.MINI_SELF_PIP);
smallLocalRender.setSelfPipMode(CallParticipantView.SelfPipMode.MINI_SELF_PIP, moreThanOneCameraAvailable);
}
private void toggleControls() {
@ -924,7 +917,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
private void updateButtonStateForLargeButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup);
overflow.setImageResource(R.drawable.webrtc_call_screen_overflow_menu);
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle);
@ -935,7 +927,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
private void updateButtonStateForSmallButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle_small);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup_small);
overflow.setImageResource(R.drawable.webrtc_call_screen_overflow_menu_small);
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small);

View file

@ -97,7 +97,7 @@ class ControlsAndInfoController(
private val handler: Handler?
get() = webRtcCallView.handler
private var previousCallControlHeight = 0
private var previousCallControlHeightData = HeightData()
private var controlPeakHeight = 0
private var controlState: WebRtcControls = WebRtcControls.NONE
@ -152,8 +152,9 @@ class ControlsAndInfoController(
}
callControls.viewTreeObserver.addOnGlobalLayoutListener {
if (callControls.height > 0 && callControls.height != previousCallControlHeight) {
previousCallControlHeight = callControls.height
if (callControls.height > 0 && previousCallControlHeightData.hasChanged(callControls.height, coordinator.height)) {
previousCallControlHeightData = HeightData(callControls.height, coordinator.height)
controlPeakHeight = callControls.height + callControls.y.toInt()
behavior.peekHeight = controlPeakHeight
frame.minimumHeight = coordinator.height / 2
@ -208,7 +209,7 @@ class ControlsAndInfoController(
}
}
fun onControlTopChanged() {
private fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
@ -320,7 +321,6 @@ class ControlsAndInfoController(
val margin = if (controlState.displaySmallCallButtons()) 4.dp else 8.dp
setControlConstraints(R.id.call_screen_speaker_toggle, controlState.displayAudioToggle(), margin)
setControlConstraints(R.id.call_screen_camera_direction_toggle, controlState.displayCameraToggle(), margin)
setControlConstraints(R.id.call_screen_video_toggle, controlState.displayVideoToggle(), margin)
setControlConstraints(R.id.call_screen_audio_mic_toggle, controlState.displayMuteAudio(), margin)
setControlConstraints(R.id.call_screen_audio_ring_toggle, controlState.displayRingToggle(), margin)
@ -456,6 +456,15 @@ class ControlsAndInfoController(
displayEndCall() != previousState.displayEndCall()
}
private data class HeightData(
val controlHeight: Int = 0,
val coordinatorHeight: Int = 0
) {
fun hasChanged(controlHeight: Int, coordinatorHeight: Int): Boolean {
return controlHeight != this.controlHeight || coordinatorHeight != this.coordinatorHeight
}
}
interface BottomSheetVisibilityListener {
fun onShown()
fun onHidden()

View file

@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.compose
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* Generic ComposeFragment which can be subclassed to build UI with compose.
*/
abstract class ComposeFullScreenDialogFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
SignalTheme(
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
) {
DialogContent()
}
}
}
}
@Composable
abstract fun DialogContent()
}

View file

@ -1071,7 +1071,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean messageRequestAccepted,
boolean allowedToPlayInline)
{
boolean showControls = messageRecord.isMediaPending() || (!messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord));
boolean showControls = !MessageRecordUtil.isScheduled(messageRecord) && (messageRecord.isMediaPending() || !messageRecord.isFailed());
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding));

View file

@ -3134,6 +3134,7 @@ class ConversationFragment :
searchNav.visible = true
searchNav.setData(0, 0)
inputPanel.setHideForSearch(true)
binding.conversationDisabledInput.visible = false
(0 until menu.size()).forEach {
if (menu.getItem(it) != searchMenuItem) {
@ -3150,6 +3151,7 @@ class ConversationFragment :
searchViewModel.onSearchClosed()
searchNav.visible = false
inputPanel.setHideForSearch(false)
binding.conversationDisabledInput.visible = true
viewModel.setSearchQuery(null)
invalidateOptionsMenu()
return true

View file

@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan
import org.thoughtcrime.securesms.util.LongClickMovementMethod
@ -66,7 +68,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
private val binding: V2ConversationItemTextOnlyBindingBridge,
private val conversationContext: V2ConversationContext,
footerDelegate: V2FooterPositionDelegate = V2FooterPositionDelegate(binding)
) : V2ConversationItemViewHolder<Model>(binding.root, conversationContext), Multiselectable, InteractiveConversationElement {
) : V2ConversationItemViewHolder<Model>(binding.root, conversationContext), Multiselectable, InteractiveConversationElement, RecipientForeverObserver {
companion object {
private val STYLE_FACTORY = SearchUtil.StyleFactory { arrayOf<CharacterStyle>(BackgroundColorSpan(Color.YELLOW), ForegroundColorSpan(Color.BLACK)) }
@ -189,7 +191,15 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
check(model is ConversationMessageElement)
if (this::conversationMessage.isInitialized) {
conversationMessage.messageRecord.fromRecipient.live().removeForeverObserver(this)
}
conversationMessage = model.conversationMessage
if (conversationMessage.threadRecipient.isGroup) {
conversationMessage.messageRecord.fromRecipient.live().observeForever(this)
}
shape = shapeDelegate.setMessageShape(
currentMessage = conversationMessage.messageRecord,
@ -765,4 +775,8 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
return true
}
}
override fun onRecipientChanged(recipient: Recipient) {
presentSender()
}
}

View file

@ -101,7 +101,6 @@ import org.thoughtcrime.securesms.components.reminder.CdsTemporaryErrorReminder;
import org.thoughtcrime.securesms.components.reminder.DozeReminder;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.OutdatedBuildReminder;
import org.thoughtcrime.securesms.components.reminder.PushRegistrationReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
@ -1000,8 +999,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
return Optional.of(new ServiceOutageReminder());
} else if (OutdatedBuildReminder.isEligible()) {
return Optional.of(new OutdatedBuildReminder(context));
} else if (PushRegistrationReminder.isEligible()) {
return Optional.of((new PushRegistrationReminder(context)));
} else if (DozeReminder.isEligible(context)) {
return Optional.of(new DozeReminder(context));
} else if (CdsTemporaryErrorReminder.isEligible()) {

View file

@ -110,6 +110,7 @@ class ConversationListViewModel(
hasNoConversations = store
.stateFlowable
.subscribeOn(Schedulers.io())
.map { it.filterRequest to it.conversations }
.distinctUntilChanged()
.map { (filterRequest, conversations) ->

View file

@ -12,6 +12,7 @@ import org.signal.core.util.delete
import org.signal.core.util.deleteAll
import org.signal.core.util.flatten
import org.signal.core.util.insertInto
import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToMap
@ -423,6 +424,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
Log.w(TAG, "[acceptOutgoingGroupCall] This shouldn't have been an outgoing ring because the call already existed!")
Event.ACCEPTED
}
else -> {
Log.d(TAG, "[acceptOutgoingGroupCall] Call in state ${call.event} cannot be transitioned by ACCEPTED")
return
@ -963,12 +965,12 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
/**
* Gets the most recent timestamp from the [TIMESTAMP] column
*/
fun getLatestTimestamp(): Long {
fun getLatestCall(): Call? {
val statement = """
SELECT $TIMESTAMP FROM $TABLE_NAME ORDER BY $TIMESTAMP DESC LIMIT 1
SELECT * FROM $TABLE_NAME ORDER BY $TIMESTAMP DESC LIMIT 1
""".trimIndent()
return readableDatabase.query(statement).readToSingleLong(-1)
return readableDatabase.query(statement).readToSingleObject { Call.deserialize(it) }
}
fun deleteNonAdHocCallEventsOnOrBefore(timestamp: Long) {
@ -1273,6 +1275,16 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val actualChildren = inPeriod.takeWhile { children.contains(it) }
val peer = Recipient.resolved(call.peer)
val canUserBeginCall = if (peer.isGroup) {
val record = SignalDatabase.groups.getGroup(peer.id)
!record.isAbsent() &&
record.get().isActive &&
(!record.get().isAnnouncementGroup || record.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR)
} else {
true
}
CallLogRow.Call(
record = call,
date = call.timestamp,
@ -1280,7 +1292,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails),
children = actualChildren.toSet(),
searchQuery = searchTerm,
callLinkPeekInfo = ApplicationDependencies.getSignalCallManager().peekInfoSnapshot[peer.id]
callLinkPeekInfo = ApplicationDependencies.getSignalCallManager().peekInfoSnapshot[peer.id],
canUserBeginCall = canUserBeginCall
)
}
}

View file

@ -49,7 +49,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH_END},
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
@ -71,13 +71,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${MessageTable.VIEW_ONCE} = 0 AND
${MessageTable.STORY_TYPE} = 0 AND
${MessageTable.LATEST_REVISION_ID} IS NULL AND
(
${AttachmentTable.QUOTE} = 0 OR
(
${AttachmentTable.QUOTE} = 1 AND
${AttachmentTable.DATA_HASH} IS NULL
)
) AND
${AttachmentTable.QUOTE} = 0 AND
${AttachmentTable.STICKER_PACK_ID} IS NULL AND
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND
$THREAD_RECIPIENT_ID > 0

View file

@ -136,7 +136,6 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.isStory
import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.Closeable
@ -4393,17 +4392,17 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
/**
* @return Unhandled ids
*/
fun setTimestampReadFromSyncMessage(readMessages: List<ReadMessage>, proposedExpireStarted: Long, threadToLatestRead: MutableMap<Long, Long>): Collection<SyncMessageId> {
fun setTimestampReadFromSyncMessage(readMessages: List<SyncMessage.Read>, proposedExpireStarted: Long, threadToLatestRead: MutableMap<Long, Long>): Collection<SyncMessageId> {
val expiringMessages: MutableList<Pair<Long, Long>> = mutableListOf()
val updatedThreads: MutableSet<Long> = mutableSetOf()
val unhandled: MutableCollection<SyncMessageId> = mutableListOf()
writableDatabase.withinTransaction {
for (readMessage in readMessages) {
val authorId: RecipientId = recipients.getOrInsertFromServiceId(readMessage.sender)
val authorId: RecipientId = recipients.getOrInsertFromServiceId(ServiceId.parseOrThrow(readMessage.senderAci!!))
val result: TimestampReadResult = setTimestampReadFromSyncMessageInternal(
messageId = SyncMessageId(authorId, readMessage.timestamp),
messageId = SyncMessageId(authorId, readMessage.timestamp!!),
proposedExpireStarted = proposedExpireStarted,
threadToLatestRead = threadToLatestRead
)
@ -4412,7 +4411,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
updatedThreads += result.threads
if (result.threads.isEmpty()) {
unhandled += SyncMessageId(authorId, readMessage.timestamp)
unhandled += SyncMessageId(authorId, readMessage.timestamp!!)
}
}
@ -4433,12 +4432,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return unhandled
}
fun setTimestampReadFromSyncMessageProto(readMessages: List<SyncMessage.Read>, proposedExpireStarted: Long, threadToLatestRead: MutableMap<Long, Long>): Collection<SyncMessageId> {
val reads: List<ReadMessage> = readMessages.map { r -> ReadMessage(ServiceId.parseOrThrow(r.senderAci!!), r.timestamp!!) }
return setTimestampReadFromSyncMessage(reads, proposedExpireStarted, threadToLatestRead)
}
/**
* Handles a synchronized read message.
* @param messageId An id representing the author-timestamp pair of the message that was read on a linked device. Note that the author could be self when

View file

@ -184,6 +184,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val PHONE_NUMBER_SHARING = "phone_number_sharing"
const val PHONE_NUMBER_DISCOVERABLE = "phone_number_discoverable"
const val PNI_SIGNATURE_VERIFIED = "pni_signature_verified"
const val NICKNAME_GIVEN_NAME = "nickname_given_name"
const val NICKNAME_FAMILY_NAME = "nickname_family_name"
const val NICKNAME_JOINED_NAME = "nickname_joined_name"
const val NOTE = "note"
const val SEARCH_PROFILE_NAME = "search_signal_profile"
const val SORT_NAME = "sort_name"
@ -252,7 +256,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$REPORTING_TOKEN BLOB DEFAULT NULL,
$PHONE_NUMBER_SHARING INTEGER DEFAULT ${PhoneNumberSharingState.UNKNOWN.id},
$PHONE_NUMBER_DISCOVERABLE INTEGER DEFAULT ${PhoneNumberDiscoverableState.UNKNOWN.id},
$PNI_SIGNATURE_VERIFIED INTEGER DEFAULT 0
$PNI_SIGNATURE_VERIFIED INTEGER DEFAULT 0,
$NICKNAME_GIVEN_NAME TEXT DEFAULT NULL,
$NICKNAME_FAMILY_NAME TEXT DEFAULT NULL,
$NICKNAME_JOINED_NAME TEXT DEFAULT NULL,
$NOTE TEXT DEFAULT NULL
)
"""
@ -312,7 +320,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
BADGES,
NEEDS_PNI_SIGNATURE,
REPORTING_TOKEN,
PHONE_NUMBER_SHARING
PHONE_NUMBER_SHARING,
NICKNAME_GIVEN_NAME,
NICKNAME_FAMILY_NAME,
NOTE
)
private val ID_PROJECTION = arrayOf(ID)
@ -333,6 +344,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
"""
LOWER(
COALESCE(
NULLIF($NICKNAME_JOINED_NAME, ''),
NULLIF($NICKNAME_GIVEN_NAME, ''),
NULLIF($SYSTEM_JOINED_NAME, ''),
NULLIF($SYSTEM_GIVEN_NAME, ''),
NULLIF($PROFILE_JOINED_NAME, ''),
@ -372,6 +385,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
"""
REPLACE(
COALESCE(
NULLIF($NICKNAME_JOINED_NAME, ''),
NULLIF($NICKNAME_GIVEN_NAME, ''),
NULLIF($SYSTEM_JOINED_NAME, ''),
NULLIF($SYSTEM_GIVEN_NAME, ''),
NULLIF($PROFILE_JOINED_NAME, ''),
@ -1722,6 +1737,20 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
fun setNicknameAndNote(id: RecipientId, nickname: ProfileName, note: String) {
val contentValues = contentValuesOf(
NICKNAME_GIVEN_NAME to nickname.givenName.nullIfBlank(),
NICKNAME_FAMILY_NAME to nickname.familyName.nullIfBlank(),
NICKNAME_JOINED_NAME to nickname.toString().nullIfBlank(),
NOTE to note.nullIfBlank()
)
if (update(id, contentValues)) {
rotateStorageId(id)
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun setProfileName(id: RecipientId, profileName: ProfileName) {
val contentValues = ContentValues(1).apply {
put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank())
@ -3207,6 +3236,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val args = searchSelection.args
val orderBy = "${if (contactSearchQuery.contactSearchSortOrder == ContactSearchSortOrder.RECENCY) "${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC, " else ""}$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $E164"
//language=roomsql
val join = if (contactSearchQuery.contactSearchSortOrder == ContactSearchSortOrder.RECENCY) {
"LEFT OUTER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$ID"
} else {
""
}
return if (contactSearchQuery.contactSearchSortOrder == ContactSearchSortOrder.RECENCY) {
val ambiguous = listOf(ID)
val projection = SEARCH_PROJECTION.map {
@ -3218,7 +3254,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
"""
SELECT ${projection.joinToString(",")}
FROM $TABLE_NAME
JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$ID
$join
WHERE $selection
ORDER BY $orderBy
""".trimIndent(),
@ -3935,6 +3971,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val profileName = ProfileName.fromParts(contact.profileGivenName.orElse(null), contact.profileFamilyName.orElse(null))
val systemName = ProfileName.fromParts(contact.systemGivenName.orElse(null), contact.systemFamilyName.orElse(null))
val username = contact.username.orElse(null)
val nickname = ProfileName.fromParts(contact.nicknameGivenName.orNull(), contact.nicknameFamilyName.orNull())
put(ACI_COLUMN, contact.aci.orElse(null)?.toString())
put(PNI_COLUMN, contact.pni.orElse(null)?.toString())
@ -3954,6 +3991,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(contact.id.raw))
put(HIDDEN, contact.isHidden)
put(PNI_SIGNATURE_VERIFIED, contact.isPniSignatureVerified.toInt())
put(NICKNAME_GIVEN_NAME, nickname.givenName.nullIfBlank())
put(NICKNAME_FAMILY_NAME, nickname.familyName.nullIfBlank())
put(NICKNAME_JOINED_NAME, nickname.toString().nullIfBlank())
put(NOTE, contact.note.orNull().nullIfBlank())
if (contact.hasUnknownFields()) {
put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(Objects.requireNonNull(contact.serializeUnknownFields())))

View file

@ -165,7 +165,9 @@ object RecipientTableCursorUtil {
needsPniSignature = cursor.requireBoolean(RecipientTable.NEEDS_PNI_SIGNATURE),
hiddenState = Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)),
callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) },
phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) }
phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) },
nickname = ProfileName.fromParts(cursor.requireString(RecipientTable.NICKNAME_GIVEN_NAME), cursor.requireString(RecipientTable.NICKNAME_FAMILY_NAME)),
note = cursor.requireString(RecipientTable.NOTE)
)
}

View file

@ -79,6 +79,8 @@ import org.thoughtcrime.securesms.database.helpers.migration.V218_RecipientPniSi
import org.thoughtcrime.securesms.database.helpers.migration.V219_PniPreKeyStores
import org.thoughtcrime.securesms.database.helpers.migration.V220_PreKeyConstraints
import org.thoughtcrime.securesms.database.helpers.migration.V221_AddReadColumnToCallEventsTable
import org.thoughtcrime.securesms.database.helpers.migration.V222_DataHashRefactor
import org.thoughtcrime.securesms.database.helpers.migration.V223_AddNicknameAndNoteFieldsToRecipientTable
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -161,10 +163,12 @@ object SignalDatabaseMigrations {
218 to V218_RecipientPniSignatureVerified,
219 to V219_PniPreKeyStores,
220 to V220_PreKeyConstraints,
221 to V221_AddReadColumnToCallEventsTable
221 to V221_AddReadColumnToCallEventsTable,
222 to V222_DataHashRefactor,
223 to V223_AddNicknameAndNoteFieldsToRecipientTable
)
const val DATABASE_VERSION = 221
const val DATABASE_VERSION = 223
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Adds the new data hash columns and indexes.
*/
@Suppress("ClassName")
object V222_DataHashRefactor : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP INDEX attachment_data_hash_index")
db.execSQL("ALTER TABLE attachment DROP COLUMN data_hash")
db.execSQL("ALTER TABLE attachment ADD COLUMN data_hash_start TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE attachment ADD COLUMN data_hash_end TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX attachment_data_hash_start_index ON attachment (data_hash_start)")
db.execSQL("CREATE INDEX attachment_data_hash_end_index ON attachment (data_hash_end)")
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Adds necessary fields to the recipeints table for the nickname & notes feature.
*/
@Suppress("ClassName")
object V223_AddNicknameAndNoteFieldsToRecipientTable : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE recipient ADD COLUMN nickname_given_name TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recipient ADD COLUMN nickname_family_name TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recipient ADD COLUMN nickname_joined_name TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recipient ADD COLUMN note TEXT DEFAULT NULL")
}
}

View file

@ -17,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
import org.thoughtcrime.securesms.util.AsyncLoader;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -76,50 +77,19 @@ public class DeviceListLoader extends AsyncLoader<List<Device>> {
throw new IOException("Got a DeviceName that wasn't properly populated.");
}
return new Device(deviceInfo.getId(), new String(decryptName(deviceName, SignalStore.account().getAciIdentityKey())), deviceInfo.getCreated(), deviceInfo.getLastSeen());
byte[] plaintext = DeviceNameCipher.decryptDeviceName(deviceName, SignalStore.account().getAciIdentityKey());
if (plaintext == null) {
throw new IOException("Failed to decrypt device name.");
}
return new Device(deviceInfo.getId(), new String(plaintext), deviceInfo.getCreated(), deviceInfo.getLastSeen());
} catch (IOException e) {
Log.w(TAG, "Failed while reading the protobuf.", e);
} catch (GeneralSecurityException | InvalidKeyException e) {
Log.w(TAG, "Failed during decryption.", e);
}
return new Device(deviceInfo.getId(), deviceInfo.getName(), deviceInfo.getCreated(), deviceInfo.getLastSeen());
}
@VisibleForTesting
public static byte[] decryptName(DeviceName deviceName, IdentityKeyPair identityKeyPair) throws InvalidKeyException, GeneralSecurityException {
byte[] syntheticIv = Objects.requireNonNull(deviceName.syntheticIv).toByteArray();
byte[] cipherText = Objects.requireNonNull(deviceName.ciphertext).toByteArray();
ECPrivateKey identityKey = identityKeyPair.getPrivateKey();
ECPublicKey ephemeralPublic = Curve.decodePoint(Objects.requireNonNull(deviceName.ephemeralPublic).toByteArray(), 0);
byte[] masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] cipherKeyPart1 = mac.doFinal("cipher".getBytes());
mac.init(new SecretKeySpec(cipherKeyPart1, "HmacSHA256"));
byte[] cipherKey = mac.doFinal(syntheticIv);
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(new byte[16]));
final byte[] plaintext = cipher.doFinal(cipherText);
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] verificationPart1 = mac.doFinal("auth".getBytes());
mac.init(new SecretKeySpec(verificationPart1, "HmacSHA256"));
byte[] verificationPart2 = mac.doFinal(plaintext);
byte[] ourSyntheticIv = ByteUtil.trim(verificationPart2, 16);
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw new GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.");
}
return plaintext;
}
private static class DeviceComparator implements Comparator<Device> {
@Override

View file

@ -234,15 +234,24 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List<UpdateDescription> updates) {
String time = ExpirationUtil.getExpirationDisplayValue(context, update.expiresInMs / 1000);
final int duration = update.expiresInMs / 1000;
String time = ExpirationUtil.getExpirationDisplayValue(context, duration);
if (update.updaterAci == null) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time), R.drawable.ic_update_timer_16));
} else {
boolean editorIsYou = selfIds.matches(update.updaterAci);
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16));
if (duration <= 0) {
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages), R.drawable.ic_update_timer_16));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_disabled_disappearing_messages, update.updaterAci, R.drawable.ic_update_timer_16));
}
} else {
updates.add(updateDescription(R.string.MessageRecord_s_set_disappearing_message_time_to_s, update.updaterAci, time, R.drawable.ic_update_timer_16));
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_set_disappearing_message_time_to_s, update.updaterAci, time, R.drawable.ic_update_timer_16));
}
}
}
}
@ -1140,11 +1149,20 @@ final class GroupsV2UpdateMessageProducer {
boolean editorIsYou = selfIds.matches(change.editorServiceIdBytes);
if (change.newTimer != null) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.newTimer.duration);
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16));
final int duration = change.newTimer.duration;
if (duration <= 0) {
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages), R.drawable.ic_update_timer_16));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_disabled_disappearing_messages, change.editorServiceIdBytes, R.drawable.ic_update_timer_16));
}
} else {
updates.add(updateDescription(R.string.MessageRecord_s_set_disappearing_message_time_to_s, change.editorServiceIdBytes, time, R.drawable.ic_update_timer_16));
String time = ExpirationUtil.getExpirationDisplayValue(context, duration);
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_set_disappearing_message_time_to_s, change.editorServiceIdBytes, time, R.drawable.ic_update_timer_16));
}
}
}
}

View file

@ -78,7 +78,9 @@ data class RecipientRecord(
val needsPniSignature: Boolean,
val hiddenState: Recipient.HiddenState,
val callLinkRoomId: CallLinkRoomId?,
val phoneNumberSharing: PhoneNumberSharingState
val phoneNumberSharing: PhoneNumberSharingState,
val nickname: ProfileName,
val note: String?
) {
fun e164Only(): Boolean {

View file

@ -85,13 +85,13 @@ public class DeleteAccountFragment extends Fragment {
}
private @NonNull CharSequence buildBulletsText(@NonNull Optional<String> formattedBalance) {
SpannableStringBuilder builder = new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)))
SpannableStringBuilder builder = new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo),8))
.append("\n")
.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages)));
.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages),8));
if (formattedBalance.isPresent()) {
builder.append("\n");
builder.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, formattedBalance.get())));
builder.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, formattedBalance.get()),8));
}
return builder;

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.moreoptions
/**
* Allows component opening sheet to specify mode
*/
enum class MoreTransferOrRestoreOptionsMode {
/**
* Only display the option to log in without transferring. Selection
* will be disabled.
*/
SKIP_ONLY,
/**
* Display transfer/restore local/skip as well as a next and cancel button
*/
SELECTION
}

View file

@ -0,0 +1,338 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.moreoptions
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
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.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
/**
* Lists a set of options the user can choose from for restoring backup or skipping restoration
*/
class MoreTransferOrRestoreOptionsSheet : ComposeBottomSheetDialogFragment() {
private val args by navArgs<MoreTransferOrRestoreOptionsSheetArgs>()
@Composable
override fun SheetContent() {
var selectedOption by remember {
mutableStateOf<BackupRestorationType?>(null)
}
MoreOptionsSheetContent(
mode = args.mode,
selectedOption = selectedOption,
onOptionSelected = { selectedOption = it },
onCancelClick = { findNavController().popBackStack() },
onNextClick = {
this.onNextClicked(selectedOption ?: BackupRestorationType.NONE)
}
)
}
private fun onNextClicked(selectedOption: BackupRestorationType) {
// TODO [message-requests] -- Launch next screen based off user choice
}
}
@Preview
@Composable
private fun MoreOptionsSheetContentPreview() {
Previews.BottomSheetPreview {
MoreOptionsSheetContent(
mode = MoreTransferOrRestoreOptionsMode.SKIP_ONLY,
selectedOption = null,
onOptionSelected = {},
onCancelClick = {},
onNextClick = {}
)
}
}
@Preview
@Composable
private fun MoreOptionsSheetSelectableContentPreview() {
Previews.BottomSheetPreview {
MoreOptionsSheetContent(
mode = MoreTransferOrRestoreOptionsMode.SELECTION,
selectedOption = null,
onOptionSelected = {},
onCancelClick = {},
onNextClick = {}
)
}
}
@Composable
private fun MoreOptionsSheetContent(
mode: MoreTransferOrRestoreOptionsMode,
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit,
onCancelClick: () -> Unit,
onNextClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(42.dp))
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
TransferFromAndroidDeviceOption(
selectedOption = selectedOption,
onOptionSelected = onOptionSelected
)
Spacer(modifier = Modifier.size(16.dp))
RestoreLocalBackupOption(
selectedOption = selectedOption,
onOptionSelected = onOptionSelected
)
Spacer(modifier = Modifier.size(16.dp))
}
LogInWithoutTransferringOption(
selectedOption = selectedOption,
onOptionSelected = when (mode) {
MoreTransferOrRestoreOptionsMode.SKIP_ONLY -> { _ -> onNextClick() }
MoreTransferOrRestoreOptionsMode.SELECTION -> onOptionSelected
}
)
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 30.dp, bottom = 24.dp)
) {
TextButton(
onClick = onCancelClick
) {
Text(text = stringResource(id = android.R.string.cancel))
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
enabled = selectedOption != null,
onClick = onNextClick
) {
Text(text = stringResource(id = R.string.RegistrationActivity_next))
}
}
} else {
Spacer(modifier = Modifier.size(45.dp))
}
}
}
@Preview
@Composable
private fun LogInWithoutTransferringOptionPreview() {
Previews.BottomSheetPreview {
LogInWithoutTransferringOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun LogInWithoutTransferringOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.NONE,
title = "Log in without transferring", // TODO [message-backups] Finalized copy.
subtitle = "Continue without transferring your messages and media", // TODO [message-backups] Finalized copy.
onClick = { onOptionSelected(BackupRestorationType.NONE) }
)
}
@Preview
@Composable
private fun TransferFromAndroidDeviceOptionPreview() {
Previews.BottomSheetPreview {
TransferFromAndroidDeviceOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun TransferFromAndroidDeviceOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.DEVICE_TRANSFER,
title = "Transfer from Android device", // TODO [message-backups] Finalized copy.
subtitle = "Transfer your account and messages from your old device.", // TODO [message-backups] Finalized copy.
onClick = { onOptionSelected(BackupRestorationType.DEVICE_TRANSFER) }
)
}
@Preview
@Composable
private fun RestoreLocalBackupOptionPreview() {
Previews.BottomSheetPreview {
RestoreLocalBackupOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun RestoreLocalBackupOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.LOCAL_BACKUP,
title = "Restore local backup", // TODO [message-backups] Finalized copy.
subtitle = "Restore your messages from a backup file you saved on your device.", // TODO [message-backups] Finalized copy.
onClick = { onOptionSelected(BackupRestorationType.LOCAL_BACKUP) }
)
}
@Preview
@Composable
private fun OptionPreview() {
Previews.BottomSheetPreview {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = false,
title = "Log in without transferring", // TODO [message-backups] Finalized copy.
subtitle = "Continue without transferring your messages and media", // TODO [message-backups] Finalized copy.
onClick = {}
)
}
}
@Composable
private fun Option(
icon: @Composable () -> Unit,
isSelected: Boolean,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
.border(
width = if (isSelected) 2.dp else 0.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
)
.clip(RoundedCornerShape(12.dp))
.clickable { onClick() }
.padding(vertical = 21.dp)
) {
icon()
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.newdevice
/**
* What kind of backup restore the user wishes to perform.
*/
enum class BackupRestorationType {
DEVICE_TRANSFER,
LOCAL_BACKUP,
REMOTE_BACKUP,
NONE
}

View file

@ -2,14 +2,17 @@ package org.thoughtcrime.securesms.devicetransfer.newdevice;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreBinding;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
@ -18,22 +21,51 @@ import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
*/
public final class TransferOrRestoreFragment extends LoggingFragment {
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private FragmentTransferRestoreBinding binding;
public TransferOrRestoreFragment() {
super(R.layout.fragment_transfer_restore);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
view.findViewById(R.id.transfer_or_restore_fragment_transfer)
.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), R.id.action_new_device_transfer_instructions));
binding = FragmentTransferRestoreBinding.bind(view);
View restoreBackup = view.findViewById(R.id.transfer_or_restore_fragment_restore);
restoreBackup.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), R.id.action_choose_backup));
TransferOrRestoreViewModel viewModel = new ViewModelProvider(this).get(TransferOrRestoreViewModel.class);
binding.transferOrRestoreFragmentTransfer.setOnClickListener(v -> viewModel.onTransferFromAndroidDeviceSelected());
binding.transferOrRestoreFragmentRestore.setOnClickListener(v -> viewModel.onRestoreFromLocalBackupSelected());
binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener(v -> viewModel.onRestoreFromRemoteBackupSelected());
binding.transferOrRestoreFragmentNext.setOnClickListener(v -> launchSelection(viewModel.getStateSnapshot()));
binding.transferOrRestoreFragmentMoreOptions.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transferOrRestore_to_moreOptions));
int visibility = FeatureFlags.messageBackups() ? View.VISIBLE : View.GONE;
binding.transferOrRestoreFragmentRestoreRemoteCard.setVisibility(visibility);
binding.transferOrRestoreFragmentMoreOptions.setVisibility(visibility);
String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device);
String toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device);
TextView transferDescriptionView = view.findViewById(R.id.transfer_or_restore_fragment_transfer_description);
transferDescriptionView.setText(SpanUtil.boldSubstring(description, toBold));
binding.transferOrRestoreFragmentTransferDescription.setText(SpanUtil.boldSubstring(description, toBold));
lifecycleDisposable.bindTo(getViewLifecycleOwner());
lifecycleDisposable.add(viewModel.getState().subscribe(this::updateSelection));
}
private void updateSelection(BackupRestorationType restorationType) {
binding.transferOrRestoreFragmentTransferCard.setSelected(restorationType == BackupRestorationType.DEVICE_TRANSFER);
binding.transferOrRestoreFragmentRestoreCard.setSelected(restorationType == BackupRestorationType.LOCAL_BACKUP);
binding.transferOrRestoreFragmentRestoreRemoteCard.setSelected(restorationType == BackupRestorationType.REMOTE_BACKUP);
}
private void launchSelection(BackupRestorationType restorationType) {
switch (restorationType) {
case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions);
case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_choose_backup);
case REMOTE_BACKUP -> {}
default -> throw new IllegalArgumentException();
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.newdevice
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.processors.BehaviorProcessor
/**
* Maintains state of the TransferOrRestoreFragment
*/
class TransferOrRestoreViewModel : ViewModel() {
private val internalState = BehaviorProcessor.createDefault(BackupRestorationType.DEVICE_TRANSFER)
val state: Flowable<BackupRestorationType> = internalState.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
val stateSnapshot: BackupRestorationType get() = internalState.value!!
fun onTransferFromAndroidDeviceSelected() {
internalState.onNext(BackupRestorationType.DEVICE_TRANSFER)
}
fun onRestoreFromLocalBackupSelected() {
internalState.onNext(BackupRestorationType.LOCAL_BACKUP)
}
fun onRestoreFromRemoteBackupSelected() {
internalState.onNext(BackupRestorationType.REMOTE_BACKUP)
}
}

View file

@ -67,7 +67,7 @@ final class OldDeviceClientTask implements ClientTask {
@Override
public void success() {
SignalStore.misc().markOldDeviceTransferLocked();
SignalStore.misc().setOldDeviceTransferLocked(true);
EventBus.getDefault().post(new Status(0, 0, 0,true));
}

View file

@ -47,7 +47,10 @@ class WebRtcViewModel(state: WebRtcServiceState) {
get() = this == CALL_PRE_JOIN || this == NETWORK_FAILURE
val isPassedPreJoin: Boolean
get() = ordinal > ordinal
get() = ordinal > CALL_PRE_JOIN.ordinal
val inOngoingCall: Boolean
get() = this == CALL_INCOMING || this == CALL_OUTGOING || this == CALL_CONNECTED || this == CALL_RINGING || this == CALL_RECONNECTING
}
enum class GroupCallState {

View file

@ -118,6 +118,14 @@ public abstract class GroupId implements DatabaseId {
}
}
public static GroupId.Push pushOrNull(byte[] bytes) {
try {
return GroupId.push(bytes);
} catch (BadGroupIdException e) {
return null;
}
}
public static @NonNull GroupId parseOrThrow(@NonNull String encodedGroupId) {
try {
return parse(encodedGroupId);

View file

@ -0,0 +1,14 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobmanager
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Helper to calculate the default backoff interval for a [Job] given it's run attempt count.
*/
fun Job.defaultBackoffInterval(): Long = BackoffUtil.exponentialBackoff(runAttempt + 1, FeatureFlags.getDefaultMaxBackoff())

View file

@ -205,7 +205,7 @@ public final class AttachmentCompressionJob extends BaseJob {
} else if (constraints.canResize(attachment)) {
Log.i(TAG, "Compressing image.");
try (MediaStream converted = compressImage(context, attachment, constraints)) {
attachmentDatabase.updateAttachmentData(attachment, converted, false);
attachmentDatabase.updateAttachmentData(attachment, converted);
}
attachmentDatabase.markAttachmentAsTransformed(attachmentId, false);
} else if (constraints.isSatisfied(context, attachment)) {
@ -263,7 +263,7 @@ public final class AttachmentCompressionJob extends BaseJob {
Log.i(TAG, "Compressing with streaming muxer");
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
File file = AttachmentTable.newFile(context);
File file = AttachmentTable.newDataFile(context);
file.deleteOnExit();
boolean faststart = false;
@ -296,7 +296,7 @@ public final class AttachmentCompressionJob extends BaseJob {
final long plaintextLength = ModernEncryptingPartOutputStream.getPlaintextLength(file.length());
try (MediaStream mediaStream = new MediaStream(postProcessor.process(plaintextLength), MimeTypes.VIDEO_MP4, 0, 0, true)) {
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
attachmentDatabase.updateAttachmentData(attachment, mediaStream);
faststart = true;
} catch (VideoPostProcessingException e) {
Log.w(TAG, "Exception thrown during post processing.", e);
@ -310,7 +310,7 @@ public final class AttachmentCompressionJob extends BaseJob {
if (!faststart) {
try (MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0, false)) {
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
attachmentDatabase.updateAttachmentData(attachment, mediaStream);
}
}
} finally {
@ -339,7 +339,7 @@ public final class AttachmentCompressionJob extends BaseJob {
100,
percent));
}, cancelationSignal)) {
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
attachmentDatabase.updateAttachmentData(attachment, mediaStream);
attachmentDatabase.markAttachmentAsTransformed(attachment.attachmentId, mediaStream.getFaststart());
}

View file

@ -168,7 +168,7 @@ public final class AttachmentDownloadJob extends BaseJob {
if (attachment.cdnNumber != ReleaseChannel.CDN_NUMBER) {
retrieveAttachment(messageId, attachmentId, attachment);
} else {
retrieveUrlAttachment(messageId, attachmentId, attachment);
retrieveAttachmentForReleaseChannel(messageId, attachmentId, attachment);
}
}
@ -216,7 +216,7 @@ public final class AttachmentDownloadJob extends BaseJob {
return isCanceled();
}
});
database.insertAttachmentsForPlaceholder(messageId, attachmentId, stream);
database.finalizeAttachmentAfterDownload(messageId, attachmentId, stream);
} catch (RangeException e) {
Log.w(TAG, "Range exception, file size " + attachmentFile.length(), e);
if (attachmentFile.delete()) {
@ -278,9 +278,9 @@ public final class AttachmentDownloadJob extends BaseJob {
}
}
private void retrieveUrlAttachment(long messageId,
final AttachmentId attachmentId,
final Attachment attachment)
private void retrieveAttachmentForReleaseChannel(long messageId,
final AttachmentId attachmentId,
final Attachment attachment)
throws IOException
{
try (Response response = S3.getObject(Objects.requireNonNull(attachment.fileName))) {
@ -289,7 +289,7 @@ public final class AttachmentDownloadJob extends BaseJob {
if (body.contentLength() > FeatureFlags.maxAttachmentReceiveSizeBytes()) {
throw new MmsException("Attachment too large, failing download");
}
SignalDatabase.attachments().insertAttachmentsForPlaceholder(messageId, attachmentId, Okio.buffer(body.source()).inputStream());
SignalDatabase.attachments().finalizeAttachmentAfterDownload(messageId, attachmentId, Okio.buffer(body.source()).inputStream());
}
} catch (MmsException e) {
Log.w(TAG, "Experienced exception while trying to download an attachment.", e);

View file

@ -0,0 +1,112 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.ThreadUtil
import org.signal.core.util.drain
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.defaultBackoffInterval
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.security.DigestInputStream
import java.security.MessageDigest
/**
* This job backfills hashes for attachments that were sent before we started hashing them.
* In order to avoid hammering the device with hash calculations and disk I/O, this job will
* calculate the hash for a single attachment and then reschedule itself to run again if necessary.
*/
class AttachmentHashBackfillJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
val TAG = Log.tag(AttachmentHashBackfillJob::class.java)
const val KEY = "AttachmentHashBackfillJob"
}
private var activeFile: File? = null
constructor() : this(
Parameters.Builder()
.setQueue(KEY)
.setMaxInstancesForFactory(2)
.setLifespan(Parameters.IMMORTAL)
.setMaxAttempts(10)
.build()
)
override fun serialize() = null
override fun getFactoryKey() = KEY
override fun run(): Result {
val (file: File?, attachmentId: AttachmentId?) = SignalDatabase.attachments.getUnhashedDataFile() ?: (null to null)
if (file == null || attachmentId == null) {
Log.i(TAG, "No more unhashed files! Task complete.")
return Result.success()
}
activeFile = file
if (!file.exists()) {
Log.w(TAG, "File does not exist! Clearing all usages.", true)
SignalDatabase.attachments.clearUsagesOfDataFile(file)
ApplicationDependencies.getJobManager().add(AttachmentHashBackfillJob())
return Result.success()
}
try {
val inputStream = SignalDatabase.attachments.getAttachmentStream(attachmentId, 0)
val messageDigest = MessageDigest.getInstance("SHA-256")
DigestInputStream(inputStream, messageDigest).use {
it.drain()
}
val hash = messageDigest.digest()
SignalDatabase.attachments.setHashForDataFile(file, hash)
} catch (e: FileNotFoundException) {
Log.w(TAG, "File could not be found! Clearing all usages.", true)
SignalDatabase.attachments.clearUsagesOfDataFile(file)
} catch (e: IOException) {
Log.e(TAG, "Error hashing attachment. Retrying.", e)
if (e.cause is FileNotFoundException) {
Log.w(TAG, "Underlying cause was a FileNotFoundException. Clearing all usages.", true)
SignalDatabase.attachments.clearUsagesOfDataFile(file)
} else {
return Result.retry(defaultBackoffInterval())
}
}
// Sleep just so we don't hammer the device with hash calculations and disk I/O
ThreadUtil.sleep(1000)
ApplicationDependencies.getJobManager().add(AttachmentHashBackfillJob())
return Result.success()
}
override fun onFailure() {
activeFile?.let { file ->
Log.w(TAG, "Failed to calculate hash, marking as unhashable: $file", true)
SignalDatabase.attachments.markDataFileAsUnhashable(file)
} ?: Log.w(TAG, "Job failed, but no active file is set!")
ApplicationDependencies.getJobManager().add(AttachmentHashBackfillJob())
}
class Factory : Job.Factory<AttachmentHashBackfillJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): AttachmentHashBackfillJob {
return AttachmentHashBackfillJob(parameters)
}
}
}

View file

@ -42,6 +42,7 @@ import java.io.IOException
import java.util.Objects
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
@ -60,7 +61,7 @@ class AttachmentUploadJob private constructor(
private val TAG = Log.tag(AttachmentUploadJob::class.java)
private val UPLOAD_REUSE_THRESHOLD = TimeUnit.DAYS.toMillis(3)
val UPLOAD_REUSE_THRESHOLD = 3.days.inWholeMilliseconds
/**
* Foreground notification shows while uploading attachments above this.
@ -162,7 +163,7 @@ class AttachmentUploadJob private constructor(
buildAttachmentStream(databaseAttachment, notification, uploadSpec!!).use { localAttachment ->
val remoteAttachment = messageSender.uploadAttachment(localAttachment)
val attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.fastPreflightId).get()
SignalDatabase.attachments.updateAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.uploadTimestamp)
SignalDatabase.attachments.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.uploadTimestamp)
}
}
} catch (e: NonSuccessfulResumableUploadResponseCodeException) {

View file

@ -5,10 +5,14 @@
package org.thoughtcrime.securesms.jobs
import androidx.annotation.WorkerThread
import okio.ByteString.Companion.toByteString
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.CallLogEventSendJobData
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException
@ -27,8 +31,9 @@ class CallLogEventSendJob private constructor(
companion object {
const val KEY = "CallLogEventSendJob"
@WorkerThread
fun forClearHistory(
timestamp: Long
call: CallTable.Call
) = CallLogEventSendJob(
Parameters.Builder()
.setQueue("CallLogEventSendJob")
@ -37,13 +42,16 @@ class CallLogEventSendJob private constructor(
.addConstraint(NetworkConstraint.KEY)
.build(),
SyncMessage.CallLogEvent(
timestamp = timestamp,
timestamp = call.timestamp,
callId = call.callId,
conversationId = Recipient.resolved(call.peer).requireCallConversationId().toByteString(),
type = SyncMessage.CallLogEvent.Type.CLEAR
)
)
@WorkerThread
fun forMarkedAsRead(
timestamp: Long
call: CallTable.Call
) = CallLogEventSendJob(
Parameters.Builder()
.setQueue("CallLogEventSendJob")
@ -52,7 +60,9 @@ class CallLogEventSendJob private constructor(
.addConstraint(NetworkConstraint.KEY)
.build(),
SyncMessage.CallLogEvent(
timestamp = timestamp,
timestamp = call.timestamp,
callId = call.callId,
conversationId = Recipient.resolved(call.peer).requireCallConversationId().toByteString(),
type = SyncMessage.CallLogEvent.Type.MARKED_AS_READ
)
)

View file

@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.migrations.AccountConsistencyMigrationJob;
import org.thoughtcrime.securesms.migrations.AccountRecordMigrationJob;
import org.thoughtcrime.securesms.migrations.ApplyUnknownFieldsToSelfMigrationJob;
import org.thoughtcrime.securesms.migrations.AttachmentCleanupMigrationJob;
import org.thoughtcrime.securesms.migrations.AttachmentHashBackfillMigrationJob;
import org.thoughtcrime.securesms.migrations.AttributesMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob;
import org.thoughtcrime.securesms.migrations.BackupJitterMigrationJob;
@ -95,6 +96,7 @@ public final class JobManagerFactories {
put(AttachmentCompressionJob.KEY, new AttachmentCompressionJob.Factory());
put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory());
put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory());
put(AttachmentHashBackfillJob.KEY, new AttachmentHashBackfillJob.Factory());
put(AttachmentMarkUploadedJob.KEY, new AttachmentMarkUploadedJob.Factory());
put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory());
put(AutomaticSessionResetJob.KEY, new AutomaticSessionResetJob.Factory());
@ -130,6 +132,7 @@ public final class JobManagerFactories {
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
put(LegacyAttachmentUploadJob.KEY, new LegacyAttachmentUploadJob.Factory());
put(LinkedDeviceInactiveCheckJob.KEY, new LinkedDeviceInactiveCheckJob.Factory());
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory());
put(MarkerJob.KEY, new MarkerJob.Factory());
@ -219,6 +222,7 @@ public final class JobManagerFactories {
put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory());
put(ApplyUnknownFieldsToSelfMigrationJob.KEY, new ApplyUnknownFieldsToSelfMigrationJob.Factory());
put(AttachmentCleanupMigrationJob.KEY, new AttachmentCleanupMigrationJob.Factory());
put(AttachmentHashBackfillMigrationJob.KEY, new AttachmentHashBackfillMigrationJob.Factory());
put(AttributesMigrationJob.KEY, new AttributesMigrationJob.Factory());
put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory());
put("AvatarMigrationJob", new FailingJob.Factory());

View file

@ -138,7 +138,7 @@ public final class LegacyAttachmentUploadJob extends BaseJob {
SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment);
Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.fastPreflightId).get();
database.updateAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.getUploadTimestamp());
database.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.getUploadTimestamp());
}
} catch (NonSuccessfulResumableUploadResponseCodeException e) {
if (e.getCode() == 400) {

View file

@ -0,0 +1,117 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* Designed as a routine check to keep an eye on how active our linked devices are.
*/
class LinkedDeviceInactiveCheckJob private constructor(
parameters: Parameters = Parameters.Builder()
.setQueue("LinkedDeviceInactiveCheckJob")
.setMaxInstancesForFactory(2)
.setLifespan(30.days.inWholeMilliseconds)
.setMaxAttempts(Parameters.UNLIMITED)
.addConstraint(NetworkConstraint.KEY)
.build()
) : Job(parameters) {
companion object {
private val TAG = Log.tag(LinkedDeviceInactiveCheckJob::class.java)
const val KEY = "LinkedDeviceInactiveCheckJob"
@JvmStatic
fun enqueue() {
ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob())
}
@JvmStatic
fun enqueueIfNecessary() {
val timeSinceLastCheck = System.currentTimeMillis() - SignalStore.misc().linkedDeviceLastActiveCheckTime
if (timeSinceLastCheck > 1.days.inWholeMilliseconds || timeSinceLastCheck < 0) {
ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob())
}
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result {
val devices = try {
ApplicationDependencies.getSignalServiceAccountManager().devices
} catch (e: IOException) {
return Result.retry(defaultBackoff())
}
if (devices.isEmpty()) {
Log.i(TAG, "No linked devices found.")
SignalStore.misc().hasLinkedDevices = false
SignalStore.misc().leastActiveLinkedDevice = null
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
val leastActiveDevice: LeastActiveLinkedDevice? = devices
.filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID }
.filter { it.name != null }
.minBy { it.lastSeen }
.let {
val nameProto = DeviceName.ADAPTER.decode(Base64.decode(it.getName()))
val decryptedBytes = DeviceNameCipher.decryptDeviceName(nameProto, ApplicationDependencies.getProtocolStore().aci().identityKeyPair) ?: return@let null
val name = String(decryptedBytes)
LeastActiveLinkedDevice(
name = name,
lastActiveTimestamp = it.lastSeen
)
}
if (leastActiveDevice == null) {
Log.w(TAG, "Failed to decrypt linked device name.")
SignalStore.misc().hasLinkedDevices = true
SignalStore.misc().leastActiveLinkedDevice = null
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
val timeSinceActive = System.currentTimeMillis() - leastActiveDevice.lastActiveTimestamp
Log.i(TAG, "Least active linked device was last active ${timeSinceActive.milliseconds.toDouble(DurationUnit.DAYS).roundedString(2)} days ago ($timeSinceActive ms).")
SignalStore.misc().hasLinkedDevices = true
SignalStore.misc().leastActiveLinkedDevice = leastActiveDevice
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
override fun onFailure() {
}
class Factory : Job.Factory<LinkedDeviceInactiveCheckJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): LinkedDeviceInactiveCheckJob {
return LinkedDeviceInactiveCheckJob(parameters)
}
}
}

View file

@ -122,8 +122,6 @@ class MultiDeviceContactSyncJob(parameters: Parameters, private val attachmentPo
}
}
recipients.setBlocked(recipient.id, contact.isBlocked)
val threadRecord = threads.getThreadRecord(threads.getThreadIdFor(recipient.id))
if (threadRecord != null && contact.isArchived != threadRecord.isArchived) {
if (contact.isArchived) {

View file

@ -168,7 +168,6 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Optional.of(ChatColorsMapper.getMaterialColor(recipient.getChatColors()).serialize()),
verifiedMessage,
ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()),
recipient.isBlocked(),
recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds())
: Optional.empty(),
Optional.ofNullable(inboxPositions.get(recipientId)),
@ -235,7 +234,6 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Optional.of(ChatColorsMapper.getMaterialColor(recipient.getChatColors()).serialize()),
verified,
profileKey,
blocked,
expireTimer,
inboxPosition,
archived.contains(recipient.getId())));
@ -253,7 +251,6 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Optional.of(ChatColorsMapper.getMaterialColor(self.getChatColors()).serialize()),
Optional.empty(),
ProfileKeyUtil.profileKeyOptionalOrThrow(self.getProfileKey()),
false,
self.getExpiresInSeconds() > 0 ? Optional.of(self.getExpiresInSeconds()) : Optional.empty(),
Optional.ofNullable(inboxPositions.get(self.getId())),
archived.contains(self.getId())));

View file

@ -84,7 +84,6 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
Optional.empty(),
Optional.empty(),
profileKey,
false,
Optional.empty(),
Optional.empty(),
false));

View file

@ -48,7 +48,7 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base
@JvmStatic
fun enqueueIfNecessary() {
if (SignalStore.misc().hasPniInitializedDevices() || !SignalStore.account().isRegistered || SignalStore.account().aci == null) {
if (SignalStore.misc().hasPniInitializedDevices || !SignalStore.account().isRegistered || SignalStore.account().aci == null) {
return
}
@ -77,19 +77,19 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base
if (!TextSecurePreferences.isMultiDevice(context)) {
Log.i(TAG, "Not multi device, aborting...")
SignalStore.misc().setPniInitializedDevices(true)
SignalStore.misc().hasPniInitializedDevices = true
return
}
if (SignalStore.account().isLinkedDevice) {
Log.i(TAG, "Not primary device, aborting...")
SignalStore.misc().setPniInitializedDevices(true)
SignalStore.misc().hasPniInitializedDevices = true
return
}
ChangeNumberRepository.CHANGE_NUMBER_LOCK.lock()
try {
if (SignalStore.misc().hasPniInitializedDevices()) {
if (SignalStore.misc().hasPniInitializedDevices) {
Log.w(TAG, "We found out that things have been initialized after we got the lock! No need to do anything else.")
return
}
@ -110,7 +110,7 @@ class PnpInitializeDevicesJob private constructor(parameters: Parameters) : Base
throw t
}
SignalStore.misc().setPniInitializedDevices(true)
SignalStore.misc().hasPniInitializedDevices = true
} finally {
ChangeNumberRepository.CHANGE_NUMBER_LOCK.unlock()
}

View file

@ -124,13 +124,18 @@ class PreKeysSyncJob private constructor(
}
val forceRotation = if (forceRotationRequested) {
warn(TAG, "Forced rotation was requested.")
warn(TAG, ServiceIdType.ACI, "Active Signed EC: ${SignalStore.account().aciPreKeys.activeSignedPreKeyId}, Last Resort Kyber: ${SignalStore.account().aciPreKeys.lastResortKyberPreKeyId}")
warn(TAG, ServiceIdType.PNI, "Active Signed EC: ${SignalStore.account().pniPreKeys.activeSignedPreKeyId}, Last Resort Kyber: ${SignalStore.account().pniPreKeys.lastResortKyberPreKeyId}")
if (!checkPreKeyConsistency(ServiceIdType.ACI, ApplicationDependencies.getProtocolStore().aci(), SignalStore.account().aciPreKeys)) {
warn(TAG, ServiceIdType.ACI, "Prekey consistency check failed! Must rotate keys!")
true
} else if (!checkPreKeyConsistency(ServiceIdType.PNI, ApplicationDependencies.getProtocolStore().pni(), SignalStore.account().pniPreKeys)) {
warn(TAG, ServiceIdType.PNI, "Prekey consistency check failed! Must rotate keys!")
warn(TAG, ServiceIdType.PNI, "Prekey consistency check failed! Must rotate keys! (ACI consistency check must have passed)")
true
} else {
warn(TAG, "Forced rotation was requested, but the consistency checks passed!")
val timeSinceLastForcedRotation = System.currentTimeMillis() - SignalStore.misc().lastForcedPreKeyRefresh
// We check < 0 in case someone changed their clock and had a bad value set
timeSinceLastForcedRotation > FeatureFlags.preKeyForceRefreshInterval() || timeSinceLastForcedRotation < 0
@ -290,10 +295,15 @@ class PreKeysSyncJob private constructor(
class Factory : Job.Factory<PreKeysSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): PreKeysSyncJob {
return serializedData?.let {
val data = PreKeysSyncJobData.ADAPTER.decode(serializedData)
PreKeysSyncJob(parameters, data.forceRefreshRequested)
} ?: PreKeysSyncJob(parameters, forceRotationRequested = false)
return try {
serializedData?.let {
val data = PreKeysSyncJobData.ADAPTER.decode(serializedData)
PreKeysSyncJob(parameters, data.forceRefreshRequested)
} ?: PreKeysSyncJob(parameters, forceRotationRequested = false)
} catch (e: IOException) {
Log.w(TAG, "Error deserializing PreKeysSyncJob", e)
PreKeysSyncJob(parameters, forceRotationRequested = false)
}
}
}
}

View file

@ -594,8 +594,13 @@ public abstract class PushSendJob extends SendJob {
SignalDatabase.messages().markAsRateLimited(messageId);
}
if (proofRequired.getOptions().contains(ProofRequiredException.Option.RECAPTCHA)) {
Log.i(TAG, "[Proof Required] ReCAPTCHA required.");
final Optional<ProofRequiredException.Option> captchaRequired =
proofRequired.getOptions().stream()
.filter(option -> option.equals(ProofRequiredException.Option.RECAPTCHA) || option.equals(ProofRequiredException.Option.CAPTCHA))
.findFirst();
if (captchaRequired.isPresent()) {
Log.i(TAG, "[Proof Required] " + captchaRequired.get() + " required.");
SignalStore.rateLimit().markNeedsRecaptcha(proofRequired.getToken());
if (recipient != null) {

View file

@ -129,7 +129,7 @@ public class RetrieveProfileAvatarJob extends BaseJob {
AvatarHelper.setAvatar(context, recipient.getId(), avatarStream);
if (recipient.isSelf()) {
SignalStore.misc().markHasEverHadAnAvatar();
SignalStore.misc().setHasEverHadAnAvatar(true);
}
} catch (AssertionError e) {
throw new IOException("Failed to copy stream. Likely a Conscrypt issue.", e);

View file

@ -196,6 +196,11 @@ public class SendViewedReceiptJob extends BaseJob {
return;
}
if (recipient.isReleaseNotes()) {
Log.w(TAG, "Refusing to send receipts to release channel");
return;
}
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient);
SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED,

View file

@ -411,10 +411,7 @@ public class StorageSyncJob extends BaseJob {
new GroupV1RecordProcessor(context).process(records.gv1, StorageSyncHelper.KEY_GENERATOR);
new GroupV2RecordProcessor(context).process(records.gv2, StorageSyncHelper.KEY_GENERATOR);
new AccountRecordProcessor(context, freshSelf()).process(records.account, StorageSyncHelper.KEY_GENERATOR);
if (getKnownTypes().contains(ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST.getValue())) {
new StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR);
}
new StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR);
}
private static @NonNull List<StorageId> getAllLocalStorageIds(@NonNull Recipient self) {

View file

@ -1,372 +0,0 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver;
import org.thoughtcrime.securesms.util.SecurePreferenceManager;
import java.util.Collections;
import java.util.List;
public final class MiscellaneousValues extends SignalStoreValues {
private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time";
private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time";
private static final String LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time";
private static final String USERNAME_SHOW_REMINDER = "username.show.reminder";
private static final String CLIENT_DEPRECATED = "misc.client_deprecated";
private static final String OLD_DEVICE_TRANSFER_LOCKED = "misc.old_device.transfer.locked";
private static final String HAS_EVER_HAD_AN_AVATAR = "misc.has.ever.had.an.avatar";
private static final String CHANGE_NUMBER_LOCK = "misc.change_number.lock";
private static final String PENDING_CHANGE_NUMBER_METADATA = "misc.pending_change_number.metadata";
private static final String CENSORSHIP_LAST_CHECK_TIME = "misc.censorship.last_check_time";
private static final String CENSORSHIP_SERVICE_REACHABLE = "misc.censorship.service_reachable";
private static final String LAST_GV2_PROFILE_CHECK_TIME = "misc.last_gv2_profile_check_time";
private static final String CDS_TOKEN = "misc.cds_token";
private static final String CDS_BLOCKED_UNTIL = "misc.cds_blocked_until";
private static final String LAST_FOREGROUND_TIME = "misc.last_foreground_time";
private static final String PNI_INITIALIZED_DEVICES = "misc.pni_initialized_devices";
private static final String LINKED_DEVICES_REMINDER = "misc.linked_devices_reminder";
private static final String HAS_LINKED_DEVICES = "misc.linked_devices_present";
private static final String USERNAME_QR_CODE_COLOR = "mis.username_qr_color_scheme";
private static final String KEYBOARD_LANDSCAPE_HEIGHT = "misc.keyboard.landscape_height";
private static final String KEYBOARD_PORTRAIT_HEIGHT = "misc.keyboard.protrait_height";
private static final String LAST_CONSISTENCY_CHECK_TIME = "misc.last_consistency_check_time";
private static final String SERVER_TIME_OFFSET = "misc.server_time_offset";
private static final String LAST_SERVER_TIME_OFFSET_UPDATE = "misc.last_server_time_offset_update";
private static final String NEEDS_USERNAME_RESTORE = "misc.needs_username_restore";
private static final String LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh";
private static final String LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync";
MiscellaneousValues(@NonNull KeyValueStore store) {
super(store);
}
@Override
void onFirstEverAppLaunch() {
putLong(MESSAGE_REQUEST_ENABLE_TIME, 0);
putBoolean(NEEDS_USERNAME_RESTORE, true);
}
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Collections.emptyList();
}
/**
* Represents the last time a _full_ prekey refreshed finished. That means signed+one-time prekeys for both ACI and PNI.
*/
public long getLastFullPrekeyRefreshTime() {
return getLong(LAST_PREKEY_REFRESH_TIME, 0);
}
public void setLastFullPrekeyRefreshTime(long time) {
putLong(LAST_PREKEY_REFRESH_TIME, time);
}
public long getMessageRequestEnableTime() {
return getLong(MESSAGE_REQUEST_ENABLE_TIME, 0);
}
public long getLastProfileRefreshTime() {
return getLong(LAST_PROFILE_REFRESH_TIME, 0);
}
public void setLastProfileRefreshTime(long time) {
putLong(LAST_PROFILE_REFRESH_TIME, time);
}
public void hideUsernameReminder() {
putBoolean(USERNAME_SHOW_REMINDER, false);
}
public boolean shouldShowUsernameReminder() {
return getBoolean(USERNAME_SHOW_REMINDER, true);
}
public boolean isClientDeprecated() {
return getBoolean(CLIENT_DEPRECATED, false);
}
public void markClientDeprecated() {
putBoolean(CLIENT_DEPRECATED, true);
}
public void clearClientDeprecated() {
putBoolean(CLIENT_DEPRECATED, false);
}
public boolean isOldDeviceTransferLocked() {
return getBoolean(OLD_DEVICE_TRANSFER_LOCKED, false);
}
public void markOldDeviceTransferLocked() {
putBoolean(OLD_DEVICE_TRANSFER_LOCKED, true);
}
public void clearOldDeviceTransferLocked() {
putBoolean(OLD_DEVICE_TRANSFER_LOCKED, false);
}
public boolean hasEverHadAnAvatar() {
return getBoolean(HAS_EVER_HAD_AN_AVATAR, false);
}
public void markHasEverHadAnAvatar() {
putBoolean(HAS_EVER_HAD_AN_AVATAR, true);
}
public boolean isChangeNumberLocked() {
return getBoolean(CHANGE_NUMBER_LOCK, false);
}
public void lockChangeNumber() {
putBoolean(CHANGE_NUMBER_LOCK, true);
ChangeNumberConstraintObserver.INSTANCE.onChange();
}
public void unlockChangeNumber() {
putBoolean(CHANGE_NUMBER_LOCK, false);
ChangeNumberConstraintObserver.INSTANCE.onChange();
}
public @Nullable PendingChangeNumberMetadata getPendingChangeNumberMetadata() {
return getObject(PENDING_CHANGE_NUMBER_METADATA, null, PendingChangeNumberMetadataSerializer.INSTANCE);
}
/** Store pending new PNI data to be applied after successful change number */
public void setPendingChangeNumberMetadata(@NonNull PendingChangeNumberMetadata metadata) {
putObject(PENDING_CHANGE_NUMBER_METADATA, metadata, PendingChangeNumberMetadataSerializer.INSTANCE);
}
/** Clear pending new PNI data after confirmed successful or failed change number */
public void clearPendingChangeNumberMetadata() {
remove(PENDING_CHANGE_NUMBER_METADATA);
}
public long getLastCensorshipServiceReachabilityCheckTime() {
return getLong(CENSORSHIP_LAST_CHECK_TIME, 0);
}
public void setLastCensorshipServiceReachabilityCheckTime(long value) {
putLong(CENSORSHIP_LAST_CHECK_TIME, value);
}
public boolean isServiceReachableWithoutCircumvention() {
return getBoolean(CENSORSHIP_SERVICE_REACHABLE, false);
}
public void setServiceReachableWithoutCircumvention(boolean value) {
putBoolean(CENSORSHIP_SERVICE_REACHABLE, value);
}
public long getLastGv2ProfileCheckTime() {
return getLong(LAST_GV2_PROFILE_CHECK_TIME, 0);
}
public void setLastGv2ProfileCheckTime(long value) {
putLong(LAST_GV2_PROFILE_CHECK_TIME, value);
}
public @Nullable byte[] getCdsToken() {
return getBlob(CDS_TOKEN, null);
}
public void setCdsToken(@Nullable byte[] token) {
getStore().beginWrite()
.putBlob(CDS_TOKEN, token)
.commit();
}
/**
* Marks the time at which we think the next CDS request will succeed. This should be taken from the service response.
*/
public void setCdsBlockedUtil(long time) {
putLong(CDS_BLOCKED_UNTIL, time);
}
/**
* Indicates that a CDS request will never succeed at the current contact count.
*/
public void markCdsPermanentlyBlocked() {
putLong(CDS_BLOCKED_UNTIL, Long.MAX_VALUE);
}
/**
* Clears any rate limiting state related to CDS.
*/
public void clearCdsBlocked() {
setCdsBlockedUtil(0);
}
/**
* Whether or not we expect the next CDS request to succeed.
*/
public boolean isCdsBlocked() {
return getCdsBlockedUtil() > 0;
}
/**
* This represents the next time we think we'll be able to make a successful CDS request. If it is before this time, we expect the request will fail
* (assuming the user still has the same number of new E164s).
*/
public long getCdsBlockedUtil() {
return getLong(CDS_BLOCKED_UNTIL, 0);
}
public long getLastForegroundTime() {
return getLong(LAST_FOREGROUND_TIME, 0);
}
public void setLastForegroundTime(long time) {
putLong(LAST_FOREGROUND_TIME, time);
}
public boolean hasPniInitializedDevices() {
return getBoolean(PNI_INITIALIZED_DEVICES, false);
}
public void setPniInitializedDevices(boolean value) {
putBoolean(PNI_INITIALIZED_DEVICES, value);
}
public void setHasLinkedDevices(boolean value) {
putBoolean(HAS_LINKED_DEVICES, value);
}
public boolean getHasLinkedDevices() {
return getBoolean(HAS_LINKED_DEVICES, false);
}
public void setShouldShowLinkedDevicesReminder(boolean value) {
putBoolean(LINKED_DEVICES_REMINDER, value);
}
public boolean getShouldShowLinkedDevicesReminder() {
return getBoolean(LINKED_DEVICES_REMINDER, false);
}
/** The color the user saved for rendering their shareable username QR code. */
public @NonNull UsernameQrCodeColorScheme getUsernameQrCodeColorScheme() {
String serialized = getString(USERNAME_QR_CODE_COLOR, null);
return UsernameQrCodeColorScheme.deserialize(serialized);
}
public void setUsernameQrCodeColorScheme(@NonNull UsernameQrCodeColorScheme color) {
putString(USERNAME_QR_CODE_COLOR, color.serialize());
}
public int getKeyboardLandscapeHeight() {
int height = (int) getLong(KEYBOARD_LANDSCAPE_HEIGHT, 0);
if (height == 0) {
//noinspection deprecation
height = SecurePreferenceManager.getSecurePreferences(ApplicationDependencies.getApplication())
.getInt("keyboard_height_landscape", 0);
if (height > 0) {
setKeyboardLandscapeHeight(height);
}
}
return height;
}
public void setKeyboardLandscapeHeight(int height) {
putLong(KEYBOARD_LANDSCAPE_HEIGHT, height);
}
public int getKeyboardPortraitHeight() {
int height = (int) getInteger(KEYBOARD_PORTRAIT_HEIGHT, 0);
if (height == 0) {
//noinspection deprecation
height = SecurePreferenceManager.getSecurePreferences(ApplicationDependencies.getApplication())
.getInt("keyboard_height_portrait", 0);
if (height > 0) {
setKeyboardPortraitHeight(height);
}
}
return height;
}
public void setKeyboardPortraitHeight(int height) {
putInteger(KEYBOARD_PORTRAIT_HEIGHT, height);
}
public long getLastConsistencyCheckTime() {
return getLong(LAST_CONSISTENCY_CHECK_TIME, 0);
}
public void setLastConsistencyCheckTime(long time) {
putLong(LAST_CONSISTENCY_CHECK_TIME, time);
}
/**
* Sets the last-known server time.
*/
public void setLastKnownServerTime(long serverTime, long currentTime) {
getStore()
.beginWrite()
.putLong(SERVER_TIME_OFFSET, currentTime - serverTime)
.putLong(LAST_SERVER_TIME_OFFSET_UPDATE, System.currentTimeMillis())
.apply();
}
/**
* The last-known offset between our local clock and the server. To get an estimate of the server time, take your current time and subtract this offset. e.g.
*
* estimatedServerTime = System.currentTimeMillis() - SignalStore.misc().getLastKnownServerTimeOffset()
*/
public long getLastKnownServerTimeOffset() {
return getLong(SERVER_TIME_OFFSET, 0);
}
/**
* The last time (using our local clock) we updated the server time offset returned by {@link #getLastKnownServerTimeOffset()}}.
*/
public long getLastKnownServerTimeOffsetUpdateTime() {
return getLong(LAST_SERVER_TIME_OFFSET_UPDATE, 0);
}
/**
* Whether or not we should attempt to restore the user's username and link.
*/
public boolean needsUsernameRestore() {
return getBoolean(NEEDS_USERNAME_RESTORE, false);
}
public void setNeedsUsernameRestore(boolean value) {
putBoolean(NEEDS_USERNAME_RESTORE, value);
}
/**
* Set the last time we successfully completed a forced prekey refresh.
*/
public void setLastForcedPreKeyRefresh(long time) {
putLong(LAST_FORCED_PREKEY_REFRESH, time);
}
/**
* Get the last time we successfully completed a forced prekey refresh.
*/
public long getLastForcedPreKeyRefresh() {
return getLong(LAST_FORCED_PREKEY_REFRESH, 0);
}
/**
* How long it's been since the last foreground CDS sync, which we do in response to new threads being created.
*/
public long getLastCdsForegroundSyncTime() {
return getLong(LAST_CDS_FOREGROUND_SYNC, 0);
}
/**
* Set the last time we did a foreground CDS sync.
*/
public void setLastCdsForegroundSyncTime(long time) {
putLong(LAST_CDS_FOREGROUND_SYNC, time);
}
}

View file

@ -0,0 +1,239 @@
package org.thoughtcrime.securesms.keyvalue
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
internal class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
private const val LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time"
private const val MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"
private const val LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time"
private const val CLIENT_DEPRECATED = "misc.client_deprecated"
private const val OLD_DEVICE_TRANSFER_LOCKED = "misc.old_device.transfer.locked"
private const val HAS_EVER_HAD_AN_AVATAR = "misc.has.ever.had.an.avatar"
private const val CHANGE_NUMBER_LOCK = "misc.change_number.lock"
private const val PENDING_CHANGE_NUMBER_METADATA = "misc.pending_change_number.metadata"
private const val CENSORSHIP_LAST_CHECK_TIME = "misc.censorship.last_check_time"
private const val CENSORSHIP_SERVICE_REACHABLE = "misc.censorship.service_reachable"
private const val LAST_GV2_PROFILE_CHECK_TIME = "misc.last_gv2_profile_check_time"
private const val CDS_TOKEN = "misc.cds_token"
private const val CDS_BLOCKED_UNTIL = "misc.cds_blocked_until"
private const val LAST_FOREGROUND_TIME = "misc.last_foreground_time"
private const val PNI_INITIALIZED_DEVICES = "misc.pni_initialized_devices"
private const val LINKED_DEVICES_REMINDER = "misc.linked_devices_reminder"
private const val HAS_LINKED_DEVICES = "misc.linked_devices_present"
private const val USERNAME_QR_CODE_COLOR = "mis.username_qr_color_scheme"
private const val KEYBOARD_LANDSCAPE_HEIGHT = "misc.keyboard.landscape_height"
private const val KEYBOARD_PORTRAIT_HEIGHT = "misc.keyboard.protrait_height"
private const val LAST_CONSISTENCY_CHECK_TIME = "misc.last_consistency_check_time"
private const val SERVER_TIME_OFFSET = "misc.server_time_offset"
private const val LAST_SERVER_TIME_OFFSET_UPDATE = "misc.last_server_time_offset_update"
private const val NEEDS_USERNAME_RESTORE = "misc.needs_username_restore"
private const val LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh"
private const val LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync"
private const val LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME = "misc.linked_device.last_active_check_time"
private const val LEAST_ACTIVE_LINKED_DEVICE = "misc.linked_device.least_active"
}
public override fun onFirstEverAppLaunch() {
putLong(MESSAGE_REQUEST_ENABLE_TIME, 0)
putBoolean(NEEDS_USERNAME_RESTORE, true)
}
public override fun getKeysToIncludeInBackup(): List<String> {
return emptyList()
}
/**
* Represents the last time a _full_ prekey refreshed finished. That means signed+one-time prekeys for both ACI and PNI.
*/
var lastFullPrekeyRefreshTime by longValue(LAST_PREKEY_REFRESH_TIME, 0)
val messageRequestEnableTime by longValue(MESSAGE_REQUEST_ENABLE_TIME, 0)
/**
* Get the last time we successfully completed a forced prekey refresh.
*/
var lastForcedPreKeyRefresh by longValue(LAST_FORCED_PREKEY_REFRESH, 0)
/**
* The last time we completed a routine profile refresh.
*/
var lastProfileRefreshTime by longValue(LAST_PROFILE_REFRESH_TIME, 0)
/**
* Whether or not the client is currently in a 'deprecated' state, disallowing network access.
*/
var isClientDeprecated: Boolean by booleanValue(CLIENT_DEPRECATED, false)
/**
* Whether or not we've locked the device after they've transferred to a new one.
*/
var isOldDeviceTransferLocked by booleanValue(OLD_DEVICE_TRANSFER_LOCKED, false)
/**
* Whether or not the user has ever had an avatar.
*/
var hasEverHadAnAvatar by booleanValue(HAS_EVER_HAD_AN_AVATAR, false)
val isChangeNumberLocked: Boolean by booleanValue(CHANGE_NUMBER_LOCK, false)
fun lockChangeNumber() {
putBoolean(CHANGE_NUMBER_LOCK, true)
ChangeNumberConstraintObserver.onChange()
}
fun unlockChangeNumber() {
putBoolean(CHANGE_NUMBER_LOCK, false)
ChangeNumberConstraintObserver.onChange()
}
val pendingChangeNumberMetadata: PendingChangeNumberMetadata?
get() = getObject(PENDING_CHANGE_NUMBER_METADATA, null, PendingChangeNumberMetadataSerializer)
/** Store pending new PNI data to be applied after successful change number */
fun setPendingChangeNumberMetadata(metadata: PendingChangeNumberMetadata) {
putObject(PENDING_CHANGE_NUMBER_METADATA, metadata, PendingChangeNumberMetadataSerializer)
}
/** Clear pending new PNI data after confirmed successful or failed change number */
fun clearPendingChangeNumberMetadata() {
remove(PENDING_CHANGE_NUMBER_METADATA)
}
/**
* The last time we checked if the service was reachable without censorship circumvention.
*/
var lastCensorshipServiceReachabilityCheckTime by longValue(CENSORSHIP_LAST_CHECK_TIME, 0)
/**
* Whether or not the service is reachable without censorship circumvention.
*/
var isServiceReachableWithoutCircumvention by booleanValue(CENSORSHIP_SERVICE_REACHABLE, false)
/**
* The last time we did a routing check to see if our GV2 groups have the latest version of our profile key.
*/
var lastGv2ProfileCheckTime by longValue(LAST_GV2_PROFILE_CHECK_TIME, 0)
/**
* The CDS token that is used for rate-limiting.
*/
var cdsToken by nullableBlobValue(CDS_TOKEN, null)
/**
* Indicates that a CDS request will never succeed at the current contact count.
*/
fun markCdsPermanentlyBlocked() {
putLong(CDS_BLOCKED_UNTIL, Long.MAX_VALUE)
}
/**
* Clears any rate limiting state related to CDS.
*/
fun clearCdsBlocked() {
cdsBlockedUtil = 0
}
/** Whether or not we expect the next CDS request to succeed.*/
val isCdsBlocked: Boolean
get() = cdsBlockedUtil > 0
/**
* This represents the next time we think we'll be able to make a successful CDS request. If it is before this time, we expect the request will fail
* (assuming the user still has the same number of new E164s).
*/
var cdsBlockedUtil by longValue(CDS_BLOCKED_UNTIL, 0)
/**
* The last time the user foregrounded the app.
*/
var lastForegroundTime by longValue(LAST_FOREGROUND_TIME, 0)
/**
* Whether or not we've done the initial "PNP Hello World" dance.
*/
var hasPniInitializedDevices by booleanValue(PNI_INITIALIZED_DEVICES, false)
/**
* Whether or not the user has linked devices.
*/
var hasLinkedDevices by booleanValue(HAS_LINKED_DEVICES, false)
/**
* Whether or not we should show a reminder for the user to relink their devices after re-registering.
*/
var shouldShowLinkedDevicesReminder by booleanValue(LINKED_DEVICES_REMINDER, false)
/**
* The color the user saved for rendering their shareable username QR code.
*/
var usernameQrCodeColorScheme: UsernameQrCodeColorScheme
get() {
val serialized = getString(USERNAME_QR_CODE_COLOR, null)
return UsernameQrCodeColorScheme.deserialize(serialized)
}
set(color) {
putString(USERNAME_QR_CODE_COLOR, color.serialize())
}
/**
* Cached landscape keyboard height.
*/
var keyboardLandscapeHeight by integerValue(KEYBOARD_LANDSCAPE_HEIGHT, 0)
/**
* Cached portrait keyboard height.
*/
var keyboardPortraitHeight by integerValue(KEYBOARD_PORTRAIT_HEIGHT, 0)
/**
* The last time we ran an account consistency check via [org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob]
*/
var lastConsistencyCheckTime by longValue(LAST_CONSISTENCY_CHECK_TIME, 0)
/**
* The last-known offset between our local clock and the server. To get an estimate of the server time, take your current time and subtract this offset. e.g.
*
* estimatedServerTime = System.currentTimeMillis() - SignalStore.misc().getLastKnownServerTimeOffset()
*/
val lastKnownServerTimeOffset by longValue(SERVER_TIME_OFFSET, 0)
/**
* The last time (using our local clock) we updated the server time offset returned by [.getLastKnownServerTimeOffset]}.
*/
val lastKnownServerTimeOffsetUpdateTime by longValue(LAST_SERVER_TIME_OFFSET_UPDATE, 0)
/**
* Sets the last-known server time.
*/
fun setLastKnownServerTime(serverTime: Long, currentTime: Long) {
store
.beginWrite()
.putLong(SERVER_TIME_OFFSET, currentTime - serverTime)
.putLong(LAST_SERVER_TIME_OFFSET_UPDATE, System.currentTimeMillis())
.apply()
}
/**
* Whether or not we should attempt to restore the user's username and link.
*/
var needsUsernameRestore by booleanValue(NEEDS_USERNAME_RESTORE, false)
/**
* How long it's been since the last foreground CDS sync, which we do in response to new threads being created.
*/
var lastCdsForegroundSyncTime by longValue(LAST_CDS_FOREGROUND_SYNC, 0)
/**
* The last time we checked for linked device activity.
*/
var linkedDeviceLastActiveCheckTime by longValue(LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME, 0)
/**
* Details about the least-active linked device.
*/
var leastActiveLinkedDevice: LeastActiveLinkedDevice? by protoValue(LEAST_ACTIVE_LINKED_DEVICE, LeastActiveLinkedDevice.ADAPTER)
}

View file

@ -28,6 +28,10 @@ internal fun SignalStoreValues.blobValue(key: String, default: ByteArray): Signa
return BlobValue(key, default, this.store)
}
internal fun SignalStoreValues.nullableBlobValue(key: String, default: ByteArray?): SignalStoreValueDelegate<ByteArray?> {
return NullableBlobValue(key, default, this.store)
}
internal fun <T : Any?> SignalStoreValues.enumValue(key: String, default: T, serializer: LongSerializer<T>): SignalStoreValueDelegate<T> {
return KeyValueEnumValue(key, default, serializer, this.store)
}
@ -114,6 +118,16 @@ private class BlobValue(private val key: String, private val default: ByteArray,
}
}
private class NullableBlobValue(private val key: String, private val default: ByteArray?, store: KeyValueStore) : SignalStoreValueDelegate<ByteArray?>(store) {
override fun getValue(values: KeyValueStore): ByteArray? {
return values.getBlob(key, default)
}
override fun setValue(values: KeyValueStore, value: ByteArray?) {
values.beginWrite().putBlob(key, value).apply()
}
}
private class KeyValueProtoValue<M>(
private val key: String,
private val adapter: ProtoAdapter<M>,

View file

@ -90,6 +90,7 @@ public class LogSectionSystemInfo implements LogSection {
builder.append("Server Time Offset: ").append(locked ? "Unknown" : getLastKnownServerTimeOffset()).append("\n");
builder.append("Telecom : ").append(locked ? "Unknown" : AndroidTelecomUtil.getTelecomSupported()).append("\n");
builder.append("User-Agent : ").append(StandardUserAgentInterceptor.USER_AGENT).append("\n");
builder.append("APNG Animation : ").append(locked ? "Unknown" : DeviceProperties.shouldAllowApngStickerAnimation(context)).append("\n");
if (BuildConfig.MANAGES_MOLLY_UPDATES) {
builder.append("Update Check URL : ").append(BuildConfig.FDROID_UPDATE_URL).append("\n");
}

View file

@ -28,12 +28,9 @@ import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureException;
import androidx.camera.core.ImageProxy;
import androidx.camera.core.resolutionselector.ResolutionSelector;
import androidx.camera.core.resolutionselector.ResolutionStrategy;
import androidx.camera.view.PreviewView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@ -50,14 +47,17 @@ import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXController;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.camerax.PlatformCameraController;
import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController;
import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations;
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
@ -70,6 +70,7 @@ import java.util.concurrent.Executors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
/**
* Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be
@ -86,11 +87,12 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private static final PreviewView.ScaleType PREVIEW_SCALE_TYPE = PreviewView.ScaleType.FILL_CENTER;
private PreviewView previewView;
private MaterialCardView cameraParent;
private ViewGroup controlsContainer;
private Controller controller;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
private SignalCameraController cameraController;
private CameraXController cameraController;
private CameraXOrientationListener orientationListener;
private Disposable mostRecentItemDisposable = Disposable.disposed();
private CameraXModePolicy cameraXModePolicy;
@ -121,6 +123,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
return fragment;
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
@ -146,7 +149,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
@SuppressLint("MissingPermission")
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
ViewGroup cameraParent = view.findViewById(R.id.camerax_camera_parent);
this.cameraParent = view.findViewById(R.id.camerax_camera_parent);
this.previewView = view.findViewById(R.id.camerax_camera);
this.controlsContainer = view.findViewById(R.id.camerax_controls_container);
@ -156,9 +159,18 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
Log.d(TAG, "Starting CameraX with mode policy " + cameraXModePolicy.getClass().getSimpleName());
View focusIndicator = view.findViewById(R.id.camerax_focus_indicator);
cameraController = new SignalCameraController(requireContext(), getViewLifecycleOwner(), previewView, focusIndicator);
previewView.setScaleType(PREVIEW_SCALE_TYPE);
if (FeatureFlags.customCameraXController()) {
View focusIndicator = view.findViewById(R.id.camerax_focus_indicator);
cameraController = new SignalCameraController(requireContext(), getViewLifecycleOwner(), previewView, focusIndicator);
} else {
PlatformCameraController platformController = new PlatformCameraController(requireContext());
platformController.initializeAndBind(requireContext(), getViewLifecycleOwner());
previewView.setController(platformController.getDelegate());
cameraController = platformController;
}
cameraXModePolicy.initialize(cameraController);
cameraScreenBrightnessController = new CameraScreenBrightnessController(
@ -169,15 +181,13 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
previewView.setScaleType(PREVIEW_SCALE_TYPE);
onOrientationChanged();
cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onViewCreated"));
if (FeatureFlags.customCameraXController()) {
cameraController.initializeAndBind(requireContext(), getViewLifecycleOwner());
}
if (requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false)) {
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setResolutionSelector(new ResolutionSelector.Builder().setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY).build())
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
imageAnalysis.setAnalyzer(qrAnalysisExecutor, imageProxy -> {
cameraController.setImageAnalysisAnalyzer(qrAnalysisExecutor, imageProxy -> {
try {
String data = qrProcessor.getScannedData(imageProxy);
if (data != null) {
@ -187,27 +197,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
imageProxy.close();
}
});
cameraController.addUseCase(imageAnalysis);
}
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// Let's assume portrait for now, so 9:16
float aspectRatio = CameraFragment.getAspectRatioForOrientation(Configuration.ORIENTATION_PORTRAIT);
float width = right - left;
float height = Math.min((1f / aspectRatio) * width, bottom - top);
ViewGroup.LayoutParams params = cameraParent.getLayoutParams();
// If there's a mismatch...
if (params.height != (int) height) {
params.width = (int) width;
params.height = (int) height;
cameraParent.setLayoutParams(params);
cameraController.setPreviewTargetSize(new Size((int) width, (int) height));
}
});
}
@Override
@ -226,16 +216,10 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
public void onResume() {
super.onResume();
cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onResume"));
cameraController.bindToLifecycle(getViewLifecycleOwner(), () -> Log.d(TAG, "Camera init complete from onResume"));
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onDestroyView() {
super.onDestroyView();
@ -278,8 +262,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private void onOrientationChanged() {
int layout = R.layout.camera_controls_portrait;
int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels);
Size size = CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, true);
int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels);
Size size = CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, true);
cameraController.setImageCaptureTargetSize(size);
@ -372,15 +356,19 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
selfieFlash = requireView().findViewById(R.id.camera_selfie_flash);
captureButton.setOnClickListener(v -> {
captureButton.setEnabled(false);
flipButton.setEnabled(false);
flashButton.setEnabled(false);
onCaptureClicked();
if (cameraController.isInitialized()) {
captureButton.setEnabled(false);
flipButton.setEnabled(false);
flashButton.setEnabled(false);
onCaptureClicked();
} else {
Log.i(TAG, "Camera capture button clicked but the camera controller is not yet initialized.");
}
});
previewView.setScaleType(PREVIEW_SCALE_TYPE);
cameraController.addInitializationCompletedListener(cameraProvider -> initializeFlipButton(flipButton, flashButton));
cameraController.addInitializationCompletedListener(ContextCompat.getMainExecutor(requireContext()), () -> initializeFlipButton(flipButton, flashButton));
flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO);
flashButton.setFlash(cameraController.getImageCaptureFlashMode());
@ -567,10 +555,10 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
}
@SuppressLint({ "MissingPermission" })
private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) {
private Unit initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) {
if (getContext() == null) {
Log.w(TAG, "initializeFlipButton called either before or after fragment was attached.");
return;
return Unit.INSTANCE;
}
getViewLifecycleOwner().getLifecycle().addObserver(cameraScreenBrightnessController);
@ -607,13 +595,14 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
} else {
flipButton.setVisibility(View.GONE);
}
return Unit.INSTANCE;
}
private static class CameraStateProvider implements CameraScreenBrightnessController.CameraStateProvider {
private final SignalCameraController cameraController;
private final CameraXController cameraController;
private CameraStateProvider(SignalCameraController cameraController) {
private CameraStateProvider(CameraXController cameraController) {
this.cameraController = cameraController;
}
@ -637,7 +626,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
@Override
public void onOrientationChanged(int orientation) {
if (cameraController != null) {
cameraController.setImageRotation(orientation);
if (FeatureFlags.customCameraXController()) {
cameraController.setImageRotation(orientation);
}
}
}
}

View file

@ -8,23 +8,24 @@ import androidx.annotation.NonNull;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageCapture;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXController;
import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController;
final class CameraXSelfieFlashHelper {
private static final float MAX_SCREEN_BRIGHTNESS = 1f;
private static final float MAX_SELFIE_FLASH_ALPHA = 0.9f;
private static final float MAX_SCREEN_BRIGHTNESS = 1f;
private static final float MAX_SELFIE_FLASH_ALPHA = 0.9f;
private final Window window;
private final SignalCameraController camera;
private final View selfieFlash;
private final Window window;
private final CameraXController camera;
private final View selfieFlash;
private float brightnessBeforeFlash;
private boolean inFlash;
private int flashMode = -1;
CameraXSelfieFlashHelper(@NonNull Window window,
@NonNull SignalCameraController camera,
@NonNull CameraXController camera,
@NonNull View selfieFlash)
{
this.window = window;

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