diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1773cecf05..2eb753c1f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard/proguard.cfg b/app/proguard/proguard.cfg index 0c57982f94..65f2de3c3b 100644 --- a/app/proguard/proguard.cfg +++ b/app/proguard/proguard.cfg @@ -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 ** { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt index c56c04f2fb..f7d55cf983 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt @@ -5,35 +5,61 @@ package org.thoughtcrime.securesms.backup.v2 +import android.Manifest +import android.app.UiAutomation +import android.os.Environment +import androidx.test.platform.app.InstrumentationRegistry import okio.ByteString.Companion.toByteString import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestName +import org.signal.core.util.Base64 +import org.signal.libsignal.messagebackup.MessageBackup +import org.signal.libsignal.messagebackup.MessageBackupKey import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.proto.AccountData import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo +import org.thoughtcrime.securesms.backup.v2.proto.BodyRange import org.thoughtcrime.securesms.backup.v2.proto.Call import org.thoughtcrime.securesms.backup.v2.proto.Chat import org.thoughtcrime.securesms.backup.v2.proto.ChatItem +import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.Contact import org.thoughtcrime.securesms.backup.v2.proto.DistributionList +import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.backup.v2.proto.Group +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 import org.thoughtcrime.securesms.backup.v2.proto.Recipient import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes import org.thoughtcrime.securesms.backup.v2.proto.Self +import org.thoughtcrime.securesms.backup.v2.proto.SendStatus +import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage import org.thoughtcrime.securesms.backup.v2.proto.StickerPack +import org.thoughtcrime.securesms.backup.v2.proto.Text +import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.kbs.MasterKey import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.api.util.toByteArray import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.util.ArrayList +import java.io.File +import java.io.FileOutputStream import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.random.Random import kotlin.time.Duration.Companion.days @@ -47,13 +73,14 @@ class ImportExportTest { val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910")) const val SELF_E164 = "+10000000000" val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32)) + val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do") val defaultBackupInfo = BackupInfo(version = 1L, backupTimeMs = 123456L) val selfRecipient = Recipient(id = 1, self = Self()) val releaseNotes = Recipient(id = 2, releaseNotes = ReleaseNotes()) val standardAccountData = AccountData( profileKey = SELF_PROFILE_KEY.serialize().toByteString(), - username = "testusername", + username = "self.01", usernameLink = null, givenName = "Peter", familyName = "Parker", @@ -81,6 +108,24 @@ class ImportExportTest { preferredReactionEmoji = listOf("a", "b", "c") ) ) + val alice = Recipient( + id = 3, + contact = Contact( + aci = TestRecipientUtils.nextAci().toByteString(), + pni = TestRecipientUtils.nextPni().toByteString(), + username = "cool.01", + e164 = 141255501234, + blocked = false, + hidden = false, + registered = Contact.Registered.REGISTERED, + unregisteredTimestamp = 0L, + profileKey = TestRecipientUtils.generateProfileKey().toByteString(), + profileSharing = true, + profileGivenName = "Alexa", + profileFamilyName = "Kim", + hideStory = true + ) + ) /** * When using standardFrames you must start recipient ids at 3. @@ -88,8 +133,13 @@ class ImportExportTest { private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, releaseNotes) } + @JvmField + @Rule + var testName = TestName() + @Before fun setup() { + SignalStore.svr().setMasterKey(MasterKey(MASTER_KEY), "1234") SignalStore.account().setE164(SELF_E164) SignalStore.account().setAci(SELF_ACI) SignalStore.account().setPni(SELF_PNI) @@ -102,6 +152,231 @@ class ImportExportTest { importExport(*standardFrames) } + @Test + fun largeNumberOfRecipientsAndChats() { + val recipients = ArrayList(5000) + val chats = ArrayList(5000) + var id = 3L + for (i in 0..5000) { + val recipientId = id++ + recipients.add( + Recipient( + id = recipientId, + contact = Contact( + aci = TestRecipientUtils.nextAci().toByteString(), + pni = TestRecipientUtils.nextPni().toByteString(), + username = "rec$i.01", + e164 = 14125550000 + i, + blocked = false, + hidden = false, + registered = Contact.Registered.REGISTERED, + unregisteredTimestamp = 0L, + profileKey = TestRecipientUtils.generateProfileKey().toByteString(), + profileSharing = true, + profileGivenName = "Test", + profileFamilyName = "Recipient$i", + hideStory = false + ) + ) + ) + chats.add( + Chat( + id = recipientId - 2L, + recipientId = recipientId + ) + ) + if (i % 10 == 0) { + val groupId = id++ + recipients.add( + Recipient( + id = groupId, + group = Group( + masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(), + whitelisted = true, + hideStory = false, + storySendMode = Group.StorySendMode.ENABLED, + name = "Cool Group $i" + ) + ) + ) + chats.add( + Chat( + id = groupId - 2L, + recipientId = groupId + ) + ) + } + } + importExport( + *standardFrames, + *recipients.toArray() + ) + } + + @Test + fun largeNumberOfMessagesAndChats() { + val NUM_INDIVIDUAL_RECIPIENTS = 1000 + val numIndividualMessages = 500 + val numGroupMessagesPerPerson = 200 + + val random = Random(1516) + + val recipients = ArrayList(1010) + val chats = ArrayList(1010) + var id = 3L + for (i in 0 until NUM_INDIVIDUAL_RECIPIENTS) { + val recipientId = id++ + recipients.add( + Recipient( + id = recipientId, + contact = Contact( + aci = TestRecipientUtils.nextAci().toByteString(), + pni = TestRecipientUtils.nextPni().toByteString(), + username = if (random.trueWithProbability(0.2f)) "rec$i.01" else null, + e164 = 14125550000 + i, + blocked = random.trueWithProbability(0.1f), + hidden = random.trueWithProbability(0.1f), + registered = Contact.Registered.REGISTERED, + unregisteredTimestamp = 0L, + profileKey = TestRecipientUtils.generateProfileKey().toByteString(), + profileSharing = random.trueWithProbability(0.9f), + profileGivenName = "Test", + profileFamilyName = "Recipient$i", + hideStory = false + ) + ) + ) + chats.add( + Chat( + id = recipientId - 2L, + recipientId = recipientId + ) + ) + if (i % 100 == 0) { + val groupId = id++ + recipients.add( + Recipient( + id = groupId, + group = Group( + masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(), + whitelisted = random.trueWithProbability(0.9f), + hideStory = random.trueWithProbability(0.1f), + storySendMode = if (random.trueWithProbability(0.9f)) Group.StorySendMode.ENABLED else Group.StorySendMode.DISABLED, + name = "Cool Group $i" + ) + ) + ) + chats.add( + Chat( + id = groupId - 2L, + recipientId = groupId + ) + ) + } + } + val chatItems = ArrayList() + var sentTime = 1L + val groupMembers = ArrayList() + var group: Recipient? = null + for (recipient in recipients) { + // Make another group and populate it with messages from these members + if (recipient.group != null) { + if (group == null) { + group = recipient + groupMembers.clear() + } else { + for (member in groupMembers) { + for (i in 0 until numGroupMessagesPerPerson) { + chatItems.add( + ChatItem( + chatId = group.id - 2L, + authorId = member.id, + dateSent = sentTime++, + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = sentTime + 1, + dateServerSent = sentTime, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Medium length message from ${member.contact?.profileGivenName} ${member.contact?.profileFamilyName} sent at $sentTime" + ) + ) + ) + ) + } + } + for (i in 0 until numGroupMessagesPerPerson) { + ChatItem( + chatId = group.id - 2L, + authorId = selfRecipient.id, + dateSent = sentTime++, + sms = false, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = groupMembers.map { groupMember -> + SendStatus(recipientId = groupMember.id, deliveryStatus = if (random.trueWithProbability(0.8f)) SendStatus.Status.READ else SendStatus.Status.DELIVERED, sealedSender = true) + } + ), + standardMessage = StandardMessage( + text = Text( + body = "Outgoing message without much text in it just because" + ) + ) + ) + } + } + } else { + groupMembers.add(recipient) + for (i in 0 until numIndividualMessages) { + if (i % 2 == 0) { + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = sentTime++, + sms = false, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf( + SendStatus(recipient.id, deliveryStatus = if (random.trueWithProbability(0.8f)) SendStatus.Status.READ else SendStatus.Status.DELIVERED, sealedSender = true) + ) + ), + standardMessage = StandardMessage( + text = Text( + body = "Outgoing message without much text in it just because" + ) + ) + ) + } else { + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = sentTime++, + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = sentTime + 1, + dateServerSent = sentTime, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Outgoing message without much text in it just because" + ) + ) + ) + } + } + } + } + val import = exportFrames( + *standardFrames, + *recipients.toArray(), + *chatItems.toArray() + ) + outputFile(import) + } + @Test fun individualRecipients() { importExport( @@ -111,7 +386,7 @@ class ImportExportTest { contact = Contact( aci = TestRecipientUtils.nextAci().toByteString(), pni = TestRecipientUtils.nextPni().toByteString(), - username = "coolusername", + username = "cool.01", e164 = 141255501234, blocked = true, hidden = true, @@ -181,7 +456,7 @@ class ImportExportTest { contact = Contact( aci = TestRecipientUtils.nextAci().toByteString(), pni = TestRecipientUtils.nextPni().toByteString(), - username = "coolusername", + username = "cool.01", e164 = 141255501234, blocked = true, hidden = true, @@ -251,7 +526,7 @@ class ImportExportTest { contact = Contact( aci = TestRecipientUtils.nextAci().toByteString(), pni = TestRecipientUtils.nextPni().toByteString(), - username = "coolusername", + username = "cool.01", e164 = 141255501234, blocked = true, hidden = true, @@ -264,7 +539,7 @@ class ImportExportTest { hideStory = true ) ) - import( + val importData = exportFrames( *standardFrames, alexa, Recipient( @@ -279,11 +554,13 @@ class ImportExportTest { ) ) ) + import(importData) val exported = export() val expected = exportFrames( *standardFrames, alexa ) + outputFile(importData, expected) compare(expected, exported) } @@ -296,7 +573,7 @@ class ImportExportTest { contact = Contact( aci = TestRecipientUtils.nextAci().toByteString(), pni = TestRecipientUtils.nextPni().toByteString(), - username = "coolusername", + username = "cool.01", e164 = 141255501234, blocked = false, hidden = false, @@ -398,7 +675,7 @@ class ImportExportTest { contact = Contact( aci = TestRecipientUtils.nextAci().toByteString(), pni = TestRecipientUtils.nextPni().toByteString(), - username = "coolusername", + username = "cool.01", e164 = 141255501234, blocked = false, hidden = false, @@ -426,6 +703,620 @@ class ImportExportTest { ) } + @Test + fun messageWithOnlyText() { + var dateSent = System.currentTimeMillis() + val sendStatuses = enumerateSendStatuses(alice.id) + val incomingMessageDetails = enumerateIncomingMessageDetails(dateSent + 200) + val outgoingMessages = ArrayList() + val incomingMessages = ArrayList() + for (sendStatus in sendStatuses) { + outgoingMessages.add( + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = dateSent++, + expireStartDate = dateSent + 1000, + expiresInMs = TimeUnit.DAYS.toMillis(2), + sms = false, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf(sendStatus) + ), + standardMessage = StandardMessage( + text = Text( + body = "Text only body" + ) + ) + ) + ) + } + dateSent++ + for (incomingDetail in incomingMessageDetails) { + incomingMessages.add( + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSent++, + expireStartDate = dateSent + 1000, + expiresInMs = TimeUnit.DAYS.toMillis(2), + sms = false, + incoming = incomingDetail, + standardMessage = StandardMessage( + text = Text( + body = "Text only body" + ) + ) + ) + ) + } + + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + *outgoingMessages.toArray(), + *incomingMessages.toArray() + ) + } + + @Test + fun messageWithTextMentionsBodyRangesAndReactions() { + val time = System.currentTimeMillis() + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = 100, + expireStartDate = time, + expiresInMs = TimeUnit.DAYS.toMillis(2), + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 105, + dateServerSent = 104, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Hey check this out I love spans!", + bodyRanges = listOf( + BodyRange( + start = 6, + length = 3, + style = BodyRange.Style.BOLD + ), + BodyRange( + start = 10, + length = 3, + style = BodyRange.Style.ITALIC + ), + BodyRange( + start = 14, + length = 3, + style = BodyRange.Style.SPOILER + ), + BodyRange( + start = 18, + length = 3, + style = BodyRange.Style.STRIKETHROUGH + ), + BodyRange( + start = 22, + length = 3, + style = BodyRange.Style.MONOSPACE + ), + BodyRange( + start = 4, + length = 0, + mentionAci = alice.contact!!.aci + ) + ) + ), + reactions = listOf( + Reaction(emoji = "F", authorId = selfRecipient.id, sentTimestamp = 302, receivedTimestamp = 303), + Reaction(emoji = "F", authorId = alice.id, sentTimestamp = 301, receivedTimestamp = 302) + ) + ) + ) + ) + } + + @Test + fun messageWithTextAndQuotes() { + val spans = listOf( + BodyRange( + start = 6, + length = 3, + style = BodyRange.Style.BOLD + ), + BodyRange( + start = 10, + length = 3, + style = BodyRange.Style.ITALIC + ), + BodyRange( + start = 14, + length = 3, + style = BodyRange.Style.SPOILER + ), + BodyRange( + start = 18, + length = 3, + style = BodyRange.Style.STRIKETHROUGH + ), + BodyRange( + start = 22, + length = 3, + style = BodyRange.Style.MONOSPACE + ) + ) + val time = System.currentTimeMillis() + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = 100, + expireStartDate = time, + expiresInMs = TimeUnit.DAYS.toMillis(2), + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 105, + dateServerSent = 104, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Hey check this out I love spans!", + bodyRanges = spans + ) + ) + ), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = 101, + expireStartDate = time, + expiresInMs = TimeUnit.DAYS.toMillis(2), + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 105, + dateServerSent = 104, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "I quoted an existing message" + ), + quote = Quote( + targetSentTimestamp = 100, + authorId = alice.id, + type = Quote.Type.NORMAL, + text = "Hey check this out I love spans!", + bodyRanges = spans + ) + ) + ), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = 102, + expireStartDate = time, + expiresInMs = TimeUnit.DAYS.toMillis(2), + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 105, + dateServerSent = 104, + read = true, + sealedSender = true + ), + standardMessage = StandardMessage( + text = Text( + body = "I quoted an non-existing message" + ), + quote = Quote( + targetSentTimestamp = 60, + authorId = alice.id, + type = Quote.Type.NORMAL, + text = "Hey check this out I love spans!", + bodyRanges = spans + ) + ) + ) + ) + } + + @Test + fun messagesNearExpirationNotExported() { + val chat = buildChat(alice, 1) + val expirationNotStarted = ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = 101, + expireStartDate = null, + expiresInMs = TimeUnit.DAYS.toMillis(1), + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 100, + dateServerSent = 100, + read = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Expiration not started but less than or equal to 1 day" + ) + ) + ) + val importData = exportFrames( + *standardFrames, + alice, + chat, + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = 100, + expireStartDate = System.currentTimeMillis(), + expiresInMs = TimeUnit.DAYS.toMillis(1), + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = 100, + dateServerSent = 100, + read = true + ), + standardMessage = StandardMessage( + text = Text( + body = "Near expiration" + ) + ) + ), + expirationNotStarted + ) + import(importData) + val exported = export() + val expected = exportFrames( + *standardFrames, + alice, + chat, + expirationNotStarted + ) + outputFile(importData, expected) + compare(expected, exported) + } + + @Test + fun messageWithAttachmentsAndQuoteAttachments() { + var dateSent = System.currentTimeMillis() + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = dateSent++, + sms = false, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf(SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, lastStatusUpdateTimestamp = -1)) + ), + standardMessage = StandardMessage( + attachments = listOf( + MessageAttachment( + pointer = FilePointer( + attachmentLocator = FilePointer.AttachmentLocator( + cdnKey = "coolCdnKey", + cdnNumber = 2, + uploadTimestamp = System.currentTimeMillis() + ), + key = (1..32).map { it.toByte() }.toByteArray().toByteString(), + contentType = "image/png", + size = 12345, + fileName = "very_cool_picture.png", + width = 100, + height = 200, + caption = "Love this cool picture!", + incrementalMacChunkSize = 0 + ) + ) + ) + ) + ) + ) + } + + @Test + fun simpleChatUpdateMessage() { + var dateSentStart = 100L + val updateMessages = ArrayList() + for (i in 1..11) { + updateMessages.add( + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + simpleUpdate = SimpleChatUpdate( + type = SimpleChatUpdate.Type.fromValue(i)!! + ) + ) + ) + ) + } + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + *updateMessages.toArray() + ) + } + + @Test + fun expirationTimerUpdateMessage() { + var dateSentStart = 100L + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + expirationTimerChange = ExpirationTimerChatUpdate( + 1000 + ) + ) + ), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = dateSentStart++, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf( + SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, sealedSender = true, lastStatusUpdateTimestamp = -1) + ) + ), + updateMessage = ChatUpdateMessage( + expirationTimerChange = ExpirationTimerChatUpdate( + 0 + ) + ) + ), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = dateSentStart++, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf(SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, sealedSender = true, lastStatusUpdateTimestamp = -1)) + ), + updateMessage = ChatUpdateMessage( + expirationTimerChange = ExpirationTimerChatUpdate( + 10000 + ) + ) + ), + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + expirationTimerChange = ExpirationTimerChatUpdate( + 0 + ) + ) + ) + ) + } + + @Test + fun profileChangeChatUpdateMessage() { + var dateSentStart = 100L + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + profileChange = ProfileChangeChatUpdate( + previousName = "Aliceee Kim", + newName = "Alice Kim" + ) + ) + ) + ) + } + + @Test + fun threadMergeChatUpdate() { + var dateSentStart = 100L + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + threadMerge = ThreadMergeChatUpdate( + previousE164 = 141255501237 + ) + ) + ) + ) + } + + @Test + fun sessionSwitchoverChatUpdate() { + var dateSentStart = 100L + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = alice.id, + dateSent = dateSentStart++, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = dateSentStart, + dateServerSent = dateSentStart, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + sessionSwitchover = SessionSwitchoverChatUpdate( + e164 = 141255501237 + ) + ) + ) + ) + } + + fun enumerateIncomingMessageDetails(dateSent: Long): List { + val details = mutableListOf() + details.add( + ChatItem.IncomingMessageDetails( + dateReceived = dateSent + 1, + dateServerSent = dateSent, + read = true, + sealedSender = true + ) + ) + details.add( + ChatItem.IncomingMessageDetails( + dateReceived = dateSent + 1, + dateServerSent = dateSent, + read = true, + sealedSender = false + ) + ) + details.add( + ChatItem.IncomingMessageDetails( + dateReceived = dateSent + 1, + dateServerSent = dateSent, + read = false, + sealedSender = true + ) + ) + details.add( + ChatItem.IncomingMessageDetails( + dateReceived = dateSent + 1, + dateServerSent = dateSent, + read = false, + sealedSender = false + ) + ) + return details + } + + fun enumerateSendStatuses(recipientId: Long): List { + val statuses = ArrayList() + val sealedSenderStates = listOf(true, false) + for (sealedSender in sealedSenderStates) { + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.DELIVERED, + sealedSender = sealedSender, + lastStatusUpdateTimestamp = -1 + ) + ) + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.PENDING, + sealedSender = sealedSender, + lastStatusUpdateTimestamp = -1, + networkFailure = true + ) + ) + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.SENT, + sealedSender = sealedSender, + lastStatusUpdateTimestamp = -1 + ) + ) + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.READ, + sealedSender = sealedSender, + lastStatusUpdateTimestamp = -1 + ) + ) + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.PENDING, + sealedSender = sealedSender, + networkFailure = true, + lastStatusUpdateTimestamp = -1 + ) + ) + statuses.add( + SendStatus( + recipientId = recipientId, + deliveryStatus = SendStatus.Status.FAILED, + sealedSender = sealedSender, + identityKeyMismatch = true, + lastStatusUpdateTimestamp = -1 + ) + ) + } + return statuses + } + + private fun buildChat(recipient: Recipient, id: Long): Chat { + return Chat( + id = id, + recipientId = recipient.id, + archived = false, + pinnedOrder = 0, + expirationTimerMs = 0, + muteUntilMs = 0, + markedUnread = false, + dontNotifyForMentionsIfMuted = false, + wallpaper = null + ) + } + /** * Export passed in frames as a backup. Does not automatically include * any standard frames (e.g. backup header). @@ -462,13 +1353,28 @@ class ImportExportTest { */ private fun import(vararg objects: Any) { val importData = exportFrames(*objects) + import(importData) + } + + private fun import(importData: ByteArray) { BackupRepository.import(length = importData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(importData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)) } /** * Export our current database as a backup. */ - private fun export() = BackupRepository.export() + private fun export(): ByteArray { + val exportData = BackupRepository.export() + return exportData + } + + private fun validate(importData: ByteArray): MessageBackup.ValidationResult { + val factory = { ByteArrayInputStream(importData) } + val masterKey = SignalStore.svr().getOrCreateMasterKey() + val key = MessageBackupKey(masterKey.serialize(), org.signal.libsignal.protocol.ServiceId.Aci.parseFromBinary(SELF_ACI.toByteArray())) + + return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, factory, importData.size.toLong()) + } /** * Imports the passed in frames and then exports them. @@ -500,6 +1406,7 @@ class ImportExportTest { } } val importData = outputStream.toByteArray() + outputFile(importData) BackupRepository.import(length = importData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(importData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)) val export = export() @@ -552,7 +1459,7 @@ class ImportExportTest { prettyAssertEquals(accountImported, accountExported) prettyAssertEquals(recipientsImported, recipientsExported) { it.id } prettyAssertEquals(chatsImported, chatsExported) { it.id } - prettyAssertEquals(chatItemsImported, chatItemsExported) + prettyAssertEquals(chatItemsImported, chatItemsExported) { it.dateSent } prettyAssertEquals(callsImported, callsExported) { it.callId } prettyAssertEquals(stickersImported, stickersExported) { it.packId } } @@ -566,6 +1473,10 @@ class ImportExportTest { } } + private fun Random.trueWithProbability(prob: Float): Boolean { + return nextFloat() < prob + } + private fun > prettyAssertEquals(import: List, export: List, selector: (T) -> R?) { if (import.size != export.size) { var msg = StringBuilder() @@ -601,4 +1512,29 @@ class ImportExportTest { return frames } + + private fun outputFile(importBytes: ByteArray, resultBytes: ByteArray? = null) { + grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + val dir = File(Environment.getExternalStorageDirectory(), "backup-tests") + if (dir.mkdirs() || dir.exists()) { + FileOutputStream(File(dir, testName.methodName + ".import")).use { + it.write(importBytes) + it.flush() + } + + if (resultBytes != null) { + FileOutputStream(File(dir, testName.methodName + ".result")).use { + it.write(resultBytes) + it.flush() + } + } + } + } + + private fun grantPermissions(vararg permissions: String?) { + val auto: UiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + for (perm in permissions) { + auto.grantRuntimePermissionAsUser(InstrumentationRegistry.getInstrumentation().targetContext.packageName, perm, android.os.Process.myUserHandle()) + } + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt index af351f00ab..ec4efe7922 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt @@ -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 diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt new file mode 100644 index 0000000000..54f1138ad3 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt @@ -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() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageContentProcessor__recipientStatusTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageContentProcessor__recipientStatusTest.kt index 606631dd18..2e7f38ba43 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageContentProcessor__recipientStatusTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageContentProcessor__recipientStatusTest.kt @@ -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) ) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt new file mode 100644 index 0000000000..36a752235c --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt @@ -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() + 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): 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) +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GroupTestingUtils.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GroupTestingUtils.kt index 284899713b..0c0d366194 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GroupTestingUtils.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GroupTestingUtils.kt @@ -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) + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt index bc2bc11dd7..38a366315b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt @@ -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>): 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 = 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() } diff --git a/app/src/instrumentation/AndroidManifest.xml b/app/src/instrumentation/AndroidManifest.xml index 93821ec6c5..8297919d99 100644 --- a/app/src/instrumentation/AndroidManifest.xml +++ b/app/src/instrumentation/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + @@ -916,11 +917,20 @@ android:windowSoftInputMode="stateVisible|adjustResize" android:exported="false"/> + + + + 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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java index ca8850efdb..7136947758 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java index 19ecadee79..ceccb0077f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java @@ -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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index 238fb15704..1af52a7417 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 4c2df2939a..678fd0fd22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt index 981ba5d880..2af8d50af8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt @@ -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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt index 92d533c84f..66175b0a33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index e37c176587..0afc5e578f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -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 output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); boolean isLegacyTable = SqlUtil.tableExists(db, "part"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 506058880e..38680114eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -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() val threadIds = HashSet() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index 233b986a2a..6813fce25b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -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> = SignalDatabase.reactions.getReactionsForMessages(records.keys) val mentionsById: Map> = SignalDatabase.mentions.getMentionsForMessages(records.keys) + val attachmentsById: Map> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys) val groupReceiptsById: Map> = 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?, mentions: List?): StandardMessage { - return StandardMessage( - quote = this.toQuote(), - text = Text( - body = this.body!!, + private fun BackupMessageRecord.toStandardMessage(reactionRecords: List?, mentions: List?, attachments: List?): 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? = 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.toBackupQuoteAttachments(): List { + 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.toBackupAttachments(): List { + return this.map { attachment -> + attachment.toBackupAttachment() + } + } + private fun List.toBackupBodyRanges(): List { return this.map { BackupBodyRange( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 3fe1131b80..8c6df58438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt index 5c483431d4..775423cdca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt index ef1aa6ab57..807e2931e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt @@ -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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreActivity.kt new file mode 100644 index 0000000000..853ccb80e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreActivity.kt @@ -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 + + 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreViewModel.kt new file mode 100644 index 0000000000..5213febed5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTestRestoreViewModel.kt @@ -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 = mutableStateOf(ScreenState(importState = ImportState.NONE, plaintext = false)) + val state: State = _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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt new file mode 100644 index 0000000000..da608ad624 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt index c15d7926a3..1cee65d231 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt @@ -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 ) - -data class MessageBackupsTypeFeature( - val iconResourceId: Int, - val label: String -) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/restore/RestoreFromBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/restore/RestoreFromBackupFragment.kt new file mode 100644 index 0000000000..c44e63e45e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/restore/RestoreFromBackupFragment.kt @@ -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, + 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) + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index b8440134dd..da4557f780 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -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(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(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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 54b4bc5084..6926691423 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt index 81bbf06834..a46805cdce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt @@ -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 { 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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt index 89f2cf4a5a..f97808e49b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt @@ -41,6 +41,7 @@ sealed class CallLogRow { val children: Set, val searchQuery: String?, val callLinkPeekInfo: CallLinkPeekInfo?, + val canUserBeginCall: Boolean, override val id: Id = Id.Call(children) ) : CallLogRow() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt index dd6eb9845f..8bf08a29cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt @@ -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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java deleted file mode 100644 index aecfd82762..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java index ea70819ce1..a11d3ec59a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UnauthorizedReminder.java @@ -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(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 9f94b1b1c9..27787fdf3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt index 38bad1abca..0b9cdc27ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 7a46ae635c..fb8e251639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt index 8ee18e1265..96a8098266 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt @@ -291,7 +291,7 @@ class ChangeNumberRepository( true ) - SignalStore.misc().setPniInitializedDevices(true) + SignalStore.misc().hasPniInitializedDevices = true ApplicationDependencies.getGroupsV2Authorization().clear() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index e6e5c6ee43..11fc36fe3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 44d9ccb118..171d045a2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -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(), ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt index e56a40e0d0..fcfe0cf644 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt index 8bea411de3..4f7b07d9de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 5047fde757..4a23e19493 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java index 04b6438d79..55b4e04343 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java index 229b9b3f2c..2ff05130c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -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. - *

+ * * 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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index 4ec64e12ab..e6f6f2b07c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt index 0bb0225e25..9d64922890 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt new file mode 100644 index 0000000000..9e78d00e88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 09e7888983..075b8e5207 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 0137563f3f..da56897ae8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt index 3bf35b4e9f..7ca9ff389f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt @@ -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>( private val binding: V2ConversationItemTextOnlyBindingBridge, private val conversationContext: V2ConversationContext, footerDelegate: V2FooterPositionDelegate = V2FooterPositionDelegate(binding) -) : V2ConversationItemViewHolder(binding.root, conversationContext), Multiselectable, InteractiveConversationElement { +) : V2ConversationItemViewHolder(binding.root, conversationContext), Multiselectable, InteractiveConversationElement, RecipientForeverObserver { companion object { private val STYLE_FACTORY = SearchUtil.StyleFactory { arrayOf(BackgroundColorSpan(Color.YELLOW), ForegroundColorSpan(Color.BLACK)) } @@ -189,7 +191,15 @@ open class V2ConversationItemTextOnlyViewHolder>( } 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>( return true } } + + override fun onRecipientChanged(recipient: Recipient) { + presentSender() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 40b2499ab5..8c523960d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt index efa5812e7d..23b772c583 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt @@ -110,6 +110,7 @@ class ConversationListViewModel( hasNoConversations = store .stateFlowable + .subscribeOn(Schedulers.io()) .map { it.filterRequest to it.conversations } .distinctUntilChanged() .map { (filterRequest, conversations) -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 71a64628ab..9e181b7b6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -32,14 +32,13 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONException -import org.signal.core.util.Base64.encodeWithPadding -import org.signal.core.util.SqlUtil.buildArgs -import org.signal.core.util.SqlUtil.buildCollectionQuery -import org.signal.core.util.SqlUtil.buildSingleCollectionQuery +import org.signal.core.util.Base64 +import org.signal.core.util.SqlUtil import org.signal.core.util.StreamUtil import org.signal.core.util.ThreadUtil import org.signal.core.util.delete import org.signal.core.util.deleteAll +import org.signal.core.util.drain import org.signal.core.util.exists import org.signal.core.util.forEach import org.signal.core.util.groupBy @@ -55,6 +54,7 @@ import org.signal.core.util.requireNonNullBlob import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.select +import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.attachments.Attachment @@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob +import org.thoughtcrime.securesms.jobs.AttachmentUploadJob import org.thoughtcrime.securesms.jobs.GenerateAudioWaveFormJob import org.thoughtcrime.securesms.mms.MediaStream import org.thoughtcrime.securesms.mms.MmsException @@ -92,6 +93,8 @@ import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.util.LinkedList import java.util.Optional +import java.util.UUID +import kotlin.time.Duration.Companion.days class AttachmentTable( context: Context, @@ -117,7 +120,8 @@ class AttachmentTable( const val DATA_FILE = "data_file" const val DATA_SIZE = "data_size" const val DATA_RANDOM = "data_random" - const val DATA_HASH = "data_hash" + const val DATA_HASH_START = "data_hash_start" + const val DATA_HASH_END = "data_hash_end" const val FILE_NAME = "file_name" const val FAST_PREFLIGHT_ID = "fast_preflight_id" const val VOICE_NOTE = "voice_note" @@ -162,7 +166,6 @@ class AttachmentTable( DATA_FILE, DATA_SIZE, DATA_RANDOM, - DATA_HASH, FILE_NAME, FAST_PREFLIGHT_ID, VOICE_NOTE, @@ -179,7 +182,9 @@ class AttachmentTable( BLUR_HASH, TRANSFORM_PROPERTIES, DISPLAY_ORDER, - UPLOAD_TIMESTAMP + UPLOAD_TIMESTAMP, + DATA_HASH_START, + DATA_HASH_END ) const val CREATE_TABLE = """ @@ -198,7 +203,6 @@ class AttachmentTable( $DATA_FILE TEXT, $DATA_SIZE INTEGER, $DATA_RANDOM BLOB, - $DATA_HASH TEXT DEFAULT NULL, $FILE_NAME TEXT, $FAST_PREFLIGHT_ID TEXT, $VOICE_NOTE INTEGER DEFAULT 0, @@ -215,7 +219,9 @@ class AttachmentTable( $BLUR_HASH TEXT DEFAULT NULL, $TRANSFORM_PROPERTIES TEXT DEFAULT NULL, $DISPLAY_ORDER INTEGER DEFAULT 0, - $UPLOAD_TIMESTAMP INTEGER DEFAULT 0 + $UPLOAD_TIMESTAMP INTEGER DEFAULT 0, + $DATA_HASH_START TEXT DEFAULT NULL, + $DATA_HASH_END TEXT DEFAULT NULL ) """ @@ -224,14 +230,17 @@ class AttachmentTable( "CREATE INDEX IF NOT EXISTS attachment_message_id_index ON $TABLE_NAME ($MESSAGE_ID);", "CREATE INDEX IF NOT EXISTS attachment_transfer_state_index ON $TABLE_NAME ($TRANSFER_STATE);", "CREATE INDEX IF NOT EXISTS attachment_sticker_pack_id_index ON $TABLE_NAME ($STICKER_PACK_ID);", - "CREATE INDEX IF NOT EXISTS attachment_data_hash_index ON $TABLE_NAME ($DATA_HASH);", + "CREATE INDEX IF NOT EXISTS attachment_data_hash_start_index ON $TABLE_NAME ($DATA_HASH_START);", + "CREATE INDEX IF NOT EXISTS attachment_data_hash_end_index ON $TABLE_NAME ($DATA_HASH_END);", "CREATE INDEX IF NOT EXISTS attachment_data_index ON $TABLE_NAME ($DATA_FILE);" ) + val ATTACHMENT_POINTER_REUSE_THRESHOLD = 7.days.inWholeMilliseconds + @JvmStatic @JvmOverloads @Throws(IOException::class) - fun newFile(context: Context): File { + fun newDataFile(context: Context): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) return PartFileProtector.protect { File.createTempFile("part", ".mms", partsDirectory) } } @@ -240,12 +249,86 @@ class AttachmentTable( @Throws(IOException::class) fun getAttachmentStream(attachmentId: AttachmentId, offset: Long): InputStream { return try { - getDataStream(attachmentId, DATA_FILE, offset) + getDataStream(attachmentId, offset) } catch (e: FileNotFoundException) { throw IOException("No stream for: $attachmentId", e) } ?: throw IOException("No stream for: $attachmentId") } + /** + * Returns a [File] for an attachment that has no [DATA_HASH_END] and is in the [TRANSFER_PROGRESS_DONE] state, if present. + */ + fun getUnhashedDataFile(): Pair? { + return readableDatabase + .select(ID, DATA_FILE) + .from(TABLE_NAME) + .where("$DATA_FILE NOT NULL AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE") + .orderBy("$ID DESC") + .limit(1) + .run() + .readToSingleObject { + File(it.requireNonNullString(DATA_FILE)) to AttachmentId(it.requireLong(ID)) + } + } + + /** + * Sets the [DATA_HASH_END] for a given file. This is used to backfill the hash for attachments that were created before we started hashing them. + * As a result, this will _not_ update the hashes on files that are not fully uploaded. + */ + fun setHashForDataFile(file: File, hash: ByteArray) { + writableDatabase.withinTransaction { db -> + val hashEnd = Base64.encodeWithPadding(hash) + + val (existingFile: String?, existingSize: Long?, existingRandom: ByteArray?) = db.select(DATA_FILE, DATA_SIZE, DATA_RANDOM) + .from(TABLE_NAME) + .where("$DATA_HASH_END = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL AND $DATA_FILE != ?", hashEnd, file.absolutePath) + .limit(1) + .run() + .readToSingleObject { + Triple( + it.requireString(DATA_FILE), + it.requireLong(DATA_SIZE), + it.requireBlob(DATA_RANDOM) + ) + } ?: Triple(null, null, null) + + if (existingFile != null) { + Log.i(TAG, "[setHashForDataFile] Found that a different file has the same HASH_END. Using that one instead. Pre-existing file: $existingFile", true) + + val updateCount = writableDatabase + .update(TABLE_NAME) + .values( + DATA_FILE to existingFile, + DATA_HASH_END to hashEnd, + DATA_SIZE to existingSize, + DATA_RANDOM to existingRandom + ) + .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) + .run() + + Log.i(TAG, "[setHashForDataFile] Deduped $updateCount attachments.", true) + + val oldFileInUse = db.exists(TABLE_NAME).where("$DATA_FILE = ?", file.absolutePath).run() + if (oldFileInUse) { + Log.i(TAG, "[setHashForDataFile] Old file is still in use by some in-progress attachment.", true) + } else { + Log.i(TAG, "[setHashForDataFile] Deleting unused file: $file") + if (!file.delete()) { + Log.w(TAG, "Failed to delete duped file!") + } + } + } else { + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(DATA_HASH_END to Base64.encodeWithPadding(hash)) + .where("$DATA_FILE = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) + .run() + + Log.i(TAG, "[setHashForDataFile] Updated the HASH_END for $updateCount rows using file ${file.absolutePath}") + } + } + } + fun getAttachment(attachmentId: AttachmentId): DatabaseAttachment? { return readableDatabase .select(*PROJECTION) @@ -273,7 +356,7 @@ class AttachmentTable( return emptyMap() } - val query = buildSingleCollectionQuery(MESSAGE_ID, mmsIds) + val query = SqlUtil.buildSingleCollectionQuery(MESSAGE_ID, mmsIds) return readableDatabase .select(*PROJECTION) @@ -317,8 +400,8 @@ class AttachmentTable( ApplicationDependencies.getJobManager().cancelAllInQueue(AttachmentDownloadJob.constructQueueString(attachmentId)) - deleteAttachmentOnDisk( - data = cursor.requireString(DATA_FILE), + deleteDataFileIfPossible( + filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = attachmentId ) @@ -367,8 +450,8 @@ class AttachmentTable( .where("$MESSAGE_ID = ?", messageId) .run() .forEach { cursor -> - deleteAttachmentOnDisk( - data = cursor.requireString(DATA_FILE), + deleteDataFileIfPossible( + filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = AttachmentId(cursor.requireLong(ID)) ) @@ -378,7 +461,8 @@ class AttachmentTable( .values( DATA_FILE to null, DATA_RANDOM to null, - DATA_HASH to null, + DATA_HASH_START to null, + DATA_HASH_END to null, FILE_NAME to null, CAPTION to null, DATA_SIZE to 0, @@ -417,8 +501,8 @@ class AttachmentTable( val data = cursor.requireString(DATA_FILE) val contentType = cursor.requireString(CONTENT_TYPE) - deleteAttachmentOnDisk( - data = data, + deleteDataFileIfPossible( + filePath = data, contentType = contentType, attachmentId = id ) @@ -427,7 +511,7 @@ class AttachmentTable( .where("$ID = ?", id.id) .run() - deleteAttachmentOnDisk(data, contentType, id) + deleteDataFileIfPossible(data, contentType, id) notifyAttachmentListeners() } } @@ -472,6 +556,34 @@ class AttachmentTable( return onDiskButNotInDatabase.size } + /** + * Removes all references to the provided [DATA_FILE] from all attachments. + * Only do this if the file is known to not exist or has some other critical problem! + */ + fun clearUsagesOfDataFile(file: File) { + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(DATA_FILE to null) + .where("$DATA_FILE = ?", file.absolutePath) + .run() + + Log.i(TAG, "[clearUsagesOfFile] Cleared $updateCount usages of $file", true) + } + + /** + * Indicates that, for whatever reason, a hash could not be calculated for the file in question. + * We put in a "bad hash" that will never match anything else so that we don't attempt to backfill it in the future. + */ + fun markDataFileAsUnhashable(file: File) { + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(DATA_HASH_END to "UNHASHABLE-${UUID.randomUUID()}") + .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) + .run() + + Log.i(TAG, "[markDataFileAsUnhashable] Marked $updateCount attachments as unhashable with file: ${file.absolutePath}", true) + } + fun deleteAllAttachments() { Log.d(TAG, "[deleteAllAttachments]") @@ -515,27 +627,50 @@ class AttachmentTable( notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } + /** + * When we find out about a new inbound attachment pointer, we insert a row for it that contains all the info we need to download it via [insertAttachmentWithData]. + * Later, we download the data for that pointer. Call this method once you have the data to associate it with the attachment. At this point, it is assumed + * that the content of the attachment will never change. + */ @Throws(MmsException::class) - fun insertAttachmentsForPlaceholder(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { - val placeholder = getAttachment(attachmentId) - val oldInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) - var dataInfo = storeAttachmentStream(inputStream) - val transferFile = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) + fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { + Log.i(TAG, "[finalizeAttachmentAfterDownload] Finalizing downloaded data for $attachmentId. (MessageId: $mmsId, $attachmentId)") - val updated = writableDatabase.withinTransaction { db -> - dataInfo = deduplicateAttachment(dataInfo, attachmentId, placeholder?.transformProperties ?: TransformProperties.empty()) + val existingPlaceholder: DatabaseAttachment = getAttachment(attachmentId) ?: throw MmsException("No attachment found for id: $attachmentId") - if (oldInfo != null) { - updateAttachmentDataHash(db, oldInfo.hash, dataInfo) - } + val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), inputStream, TransformProperties.empty()) + val transferFile: File? = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) + + val foundDuplicate = writableDatabase.withinTransaction { db -> + // We can look and see if we have any exact matches on hash_ends and dedupe the file if we see one. + // We don't look at hash_start here because that could result in us matching on a file that got compressed down to something smaller, effectively lowering + // the quality of the attachment we received. + val hashMatch: DataFileInfo? = readableDatabase + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) + .from(TABLE_NAME) + .where("$DATA_HASH_END = ? AND $DATA_HASH_END NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL", fileWriteResult.hash) + .run() + .readToList { it.readDataFileInfo() } + .firstOrNull() val values = ContentValues() - values.put(DATA_FILE, dataInfo.file.absolutePath) - values.put(DATA_SIZE, dataInfo.length) - values.put(DATA_RANDOM, dataInfo.random) - values.put(DATA_HASH, dataInfo.hash) - val visualHashString = placeholder.getVisualHashStringOrNull() + if (hashMatch != null) { + Log.i(TAG, "[finalizeAttachmentAfterDownload] Found that ${hashMatch.id} has the same DATA_HASH_END. Deduping. (MessageId: $mmsId, $attachmentId)") + values.put(DATA_FILE, hashMatch.file.absolutePath) + values.put(DATA_SIZE, hashMatch.length) + values.put(DATA_RANDOM, hashMatch.random) + values.put(DATA_HASH_START, hashMatch.hashEnd) + values.put(DATA_HASH_END, hashMatch.hashEnd) + } else { + values.put(DATA_FILE, fileWriteResult.file.absolutePath) + values.put(DATA_SIZE, fileWriteResult.length) + values.put(DATA_RANDOM, fileWriteResult.random) + values.put(DATA_HASH_START, fileWriteResult.hash) + values.put(DATA_HASH_END, fileWriteResult.hash) + } + + val visualHashString = existingPlaceholder.getVisualHashStringOrNull() if (visualHashString != null) { values.put(BLUR_HASH, visualHashString) } @@ -544,26 +679,26 @@ class AttachmentTable( values.put(TRANSFER_FILE, null as String?) values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize()) - val updateCount = db.update(TABLE_NAME) + db.update(TABLE_NAME) .values(values) .where("$ID = ?", attachmentId.id) .run() - updateCount > 0 + hashMatch != null } - if (updated) { - val threadId = messages.getThreadIdForMessage(mmsId) + val threadId = messages.getThreadIdForMessage(mmsId) - if (!messages.isStory(mmsId)) { - threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) - } + if (!messages.isStory(mmsId)) { + threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) + } - notifyConversationListeners(threadId) - notifyConversationListListeners() - notifyAttachmentListeners() - } else { - if (!dataInfo.file.delete()) { + notifyConversationListeners(threadId) + notifyConversationListListeners() + notifyAttachmentListeners() + + if (foundDuplicate) { + if (!fileWriteResult.file.delete()) { Log.w(TAG, "Failed to delete unused attachment") } } @@ -574,21 +709,67 @@ class AttachmentTable( } } - if (placeholder != null && MediaUtil.isAudio(placeholder)) { - GenerateAudioWaveFormJob.enqueue(placeholder.attachmentId) + if (MediaUtil.isAudio(existingPlaceholder)) { + GenerateAudioWaveFormJob.enqueue(existingPlaceholder.attachmentId) + } + } + + /** + * Needs to be called after an attachment is successfully uploaded. Writes metadata around it's final remote location, as well as calculates + * it's ending hash, which is critical for backups. + */ + @Throws(IOException::class) + fun finalizeAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { + Log.i(TAG, "[finalizeAttachmentAfterUpload] Finalizing upload for $id.") + + val dataStream = getAttachmentStream(id, 0) + val messageDigest = MessageDigest.getInstance("SHA-256") + + DigestInputStream(dataStream, messageDigest).use { + it.drain() + } + + val dataHashEnd = Base64.encodeWithPadding(messageDigest.digest()) + + val values = contentValuesOf( + TRANSFER_STATE to TRANSFER_PROGRESS_DONE, + CDN_NUMBER to attachment.cdnNumber, + REMOTE_LOCATION to attachment.remoteLocation, + REMOTE_DIGEST to attachment.remoteDigest, + REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, + REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, + REMOTE_KEY to attachment.remoteKey, + DATA_SIZE to attachment.size, + DATA_HASH_END to dataHashEnd, + FAST_PREFLIGHT_ID to attachment.fastPreflightId, + BLUR_HASH to attachment.getVisualHashStringOrNull(), + UPLOAD_TIMESTAMP to uploadTimestamp + ) + + val dataFilePath = getDataFilePath(id) ?: throw IOException("No data file found for attachment!") + + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(values) + .where("$ID = ? OR $DATA_FILE = ?", id.id, dataFilePath) + .run() + + if (updateCount <= 0) { + Log.w(TAG, "[finalizeAttachmentAfterUpload] Failed to update attachment after upload! $id") } } @Throws(MmsException::class) fun copyAttachmentData(sourceId: AttachmentId, destinationId: AttachmentId) { val sourceAttachment = getAttachment(sourceId) ?: throw MmsException("Cannot find attachment for source!") - val sourceDataInfo = getAttachmentDataFileInfo(sourceId, DATA_FILE) ?: throw MmsException("No attachment data found for source!") + val sourceDataInfo = getDataFileInfo(sourceId) ?: throw MmsException("No attachment data found for source!") writableDatabase .update(TABLE_NAME) .values( DATA_FILE to sourceDataInfo.file.absolutePath, - DATA_HASH to sourceDataInfo.hash, + DATA_HASH_START to sourceDataInfo.hashStart, + DATA_HASH_END to sourceDataInfo.hashEnd, DATA_SIZE to sourceDataInfo.length, DATA_RANDOM to sourceDataInfo.random, TRANSFER_STATE to sourceAttachment.transferState, @@ -629,33 +810,6 @@ class AttachmentTable( } } - fun updateAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { - val dataInfo = getAttachmentDataFileInfo(id, DATA_FILE) - val values = contentValuesOf( - TRANSFER_STATE to TRANSFER_PROGRESS_DONE, - CDN_NUMBER to attachment.cdnNumber, - REMOTE_LOCATION to attachment.remoteLocation, - REMOTE_DIGEST to attachment.remoteDigest, - REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, - REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, - REMOTE_KEY to attachment.remoteKey, - DATA_SIZE to attachment.size, - FAST_PREFLIGHT_ID to attachment.fastPreflightId, - BLUR_HASH to attachment.getVisualHashStringOrNull(), - UPLOAD_TIMESTAMP to uploadTimestamp - ) - - if (dataInfo?.hash != null) { - updateAttachmentAndMatchingHashes(writableDatabase, id, dataInfo.hash, values) - } else { - writableDatabase - .update(TABLE_NAME) - .values(values) - .where("$ID = ?", id.id) - .run() - } - } - @Throws(MmsException::class) fun insertAttachmentForPreUpload(attachment: Attachment): DatabaseAttachment { val result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, listOf(attachment), emptyList()) @@ -691,26 +845,42 @@ class AttachmentTable( } } + /** + * Inserts new attachments in the table. The [Attachment]s may or may not have data, depending on whether it's an attachment we created locally or some + * inbound attachment that we haven't fetched yet. + * + * If the attachment has no data, it is assumed that you will later call [finalizeAttachmentAfterDownload]. + */ @Throws(MmsException::class) fun insertAttachmentsForMessage(mmsId: Long, attachments: List, quoteAttachment: List): Map { if (attachments.isEmpty() && quoteAttachment.isEmpty()) { return emptyMap() } - Log.d(TAG, "insertParts(${attachments.size})") + Log.d(TAG, "[insertAttachmentsForMessage] insertParts(${attachments.size})") val insertedAttachments: MutableMap = mutableMapOf() for (attachment in attachments) { - val attachmentId = insertAttachment(mmsId, attachment, attachment.quote) + val attachmentId = if (attachment.uri != null) { + insertAttachmentWithData(mmsId, attachment, attachment.quote) + } else { + insertUndownloadedAttachment(mmsId, attachment, attachment.quote) + } + insertedAttachments[attachment] = attachmentId - Log.i(TAG, "Inserted attachment at ID: $attachmentId") + Log.i(TAG, "[insertAttachmentsForMessage] Inserted attachment at $attachmentId") } try { for (attachment in quoteAttachment) { - val attachmentId = insertAttachment(mmsId, attachment, true) + val attachmentId = if (attachment.uri != null) { + insertAttachmentWithData(mmsId, attachment, true) + } else { + insertUndownloadedAttachment(mmsId, attachment, true) + } + insertedAttachments[attachment] = attachmentId - Log.i(TAG, "Inserted quoted attachment at ID: $attachmentId") + Log.i(TAG, "[insertAttachmentsForMessage] Inserted quoted attachment at $attachmentId") } } catch (e: MmsException) { Log.w(TAG, "Failed to insert quote attachment! messageId: $mmsId") @@ -720,46 +890,33 @@ class AttachmentTable( } /** - * @param onlyModifyThisAttachment If false and more than one attachment shares this file and quality, they will all - * be updated. If true, then guarantees not to affect other attachments. + * Updates the data stored for an existing attachment. This happens after transformations, like transcoding. */ @Throws(MmsException::class, IOException::class) fun updateAttachmentData( databaseAttachment: DatabaseAttachment, - mediaStream: MediaStream, - onlyModifyThisAttachment: Boolean + mediaStream: MediaStream ) { val attachmentId = databaseAttachment.attachmentId - val oldDataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) ?: throw MmsException("No attachment data found!") - var destination = oldDataInfo.file - val isSingleUseOfData = onlyModifyThisAttachment || oldDataInfo.hash == null + val existingDataFileInfo: DataFileInfo = getDataFileInfo(attachmentId) ?: throw MmsException("No attachment data found!") + val newDataFileInfo: DataFileWriteResult = writeToDataFile(existingDataFileInfo.file, mediaStream.stream, databaseAttachment.transformProperties ?: TransformProperties.empty()) - if (isSingleUseOfData && fileReferencedByMoreThanOneAttachment(destination)) { - Log.i(TAG, "Creating a new file as this one is used by more than one attachment") - destination = newFile(context) - } - - var dataInfo: DataInfo = storeAttachmentStream(destination, mediaStream.stream) + // TODO We don't dedupe here because we're assuming that we should have caught any dupe scenarios on first insert. We could consider doing dupe checks here though. writableDatabase.withinTransaction { db -> - dataInfo = deduplicateAttachment(dataInfo, attachmentId, databaseAttachment.transformProperties) - val contentValues = contentValuesOf( - DATA_SIZE to dataInfo.length, + DATA_SIZE to newDataFileInfo.length, CONTENT_TYPE to mediaStream.mimeType, WIDTH to mediaStream.width, HEIGHT to mediaStream.height, - DATA_FILE to dataInfo.file.absolutePath, - DATA_RANDOM to dataInfo.random, - DATA_HASH to dataInfo.hash + DATA_FILE to newDataFileInfo.file.absolutePath, + DATA_RANDOM to newDataFileInfo.random ) - val updateCount = updateAttachmentAndMatchingHashes( - db = db, - attachmentId = attachmentId, - dataHash = if (isSingleUseOfData) dataInfo.hash else oldDataInfo.hash, - contentValues = contentValues - ) + val updateCount = db.update(TABLE_NAME) + .values(contentValues) + .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, existingDataFileInfo.file.absolutePath) + .run() Log.i(TAG, "[updateAttachmentData] Updated $updateCount rows.") } @@ -767,14 +924,14 @@ class AttachmentTable( fun duplicateAttachmentsForMessage(destinationMessageId: Long, sourceMessageId: Long, excludedIds: Collection) { writableDatabase.withinTransaction { db -> - db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", buildArgs(sourceMessageId)) + db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", SqlUtil.buildArgs(sourceMessageId)) - val queries = buildCollectionQuery(ID, excludedIds) + val queries = SqlUtil.buildCollectionQuery(ID, excludedIds) for (query in queries) { db.delete("tmp_part", query.where, query.whereArgs) } - db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", buildArgs(destinationMessageId)) + db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", SqlUtil.buildArgs(destinationMessageId)) db.execSQL("INSERT INTO $TABLE_NAME SELECT * FROM tmp_part") db.execSQL("DROP TABLE tmp_part") } @@ -798,45 +955,54 @@ class AttachmentTable( return transferFile } - @VisibleForTesting - fun getAttachmentDataFileInfo(attachmentId: AttachmentId, dataType: String): DataInfo? { + fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? { return readableDatabase - .select(dataType, DATA_SIZE, DATA_RANDOM, DATA_HASH, TRANSFORM_PROPERTIES) + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToSingleObject { cursor -> - if (cursor.isNull(dataType)) { + if (cursor.isNull(DATA_FILE)) { null } else { - DataInfo( - file = File(cursor.getString(cursor.getColumnIndexOrThrow(dataType))), - length = cursor.requireLong(DATA_SIZE), - random = cursor.requireNonNullBlob(DATA_RANDOM), - hash = cursor.requireString(DATA_HASH), - transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)) - ) + cursor.readDataFileInfo() } } } + fun getDataFilePath(attachmentId: AttachmentId): String? { + return readableDatabase + .select(DATA_FILE) + .from(TABLE_NAME) + .where("$ID = ?", attachmentId.id) + .run() + .readToSingleObject { it.requireString(DATA_FILE) } + } + fun markAttachmentAsTransformed(attachmentId: AttachmentId, withFastStart: Boolean) { + Log.i(TAG, "[markAttachmentAsTransformed] Marking $attachmentId as transformed. withFastStart: $withFastStart") writableDatabase.withinTransaction { db -> try { - var transformProperties = getTransformProperties(attachmentId) - if (transformProperties == null) { - Log.w(TAG, "Failed to get transformation properties, attachment no longer exists.") + val dataInfo = getDataFileInfo(attachmentId) + if (dataInfo == null) { + Log.w(TAG, "[markAttachmentAsTransformed] Failed to get transformation properties, attachment no longer exists.") return@withinTransaction } - transformProperties = transformProperties.withSkipTransform() + var transformProperties = dataInfo.transformProperties.withSkipTransform() if (withFastStart) { transformProperties = transformProperties.withMp4FastStart() } - updateAttachmentTransformProperties(attachmentId, transformProperties) + val count = writableDatabase + .update(TABLE_NAME) + .values(TRANSFORM_PROPERTIES to transformProperties.serialize()) + .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, dataInfo.file.absolutePath) + .run() + + Log.i(TAG, "[markAttachmentAsTransformed] Updated $count rows.") } catch (e: Exception) { - Log.w(TAG, "Could not mark attachment as transformed.", e) + Log.w(TAG, "[markAttachmentAsTransformed] Could not mark attachment as transformed.", e) } } } @@ -853,7 +1019,7 @@ class AttachmentTable( @RequiresApi(23) fun mediaDataSourceFor(attachmentId: AttachmentId, allowReadingFromTempFile: Boolean): MediaDataSource? { - val dataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) + val dataInfo = getDataFileInfo(attachmentId) if (dataInfo != null) { return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length) } @@ -949,7 +1115,7 @@ class AttachmentTable( transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), displayOrder = jsonObject.getInt(DISPLAY_ORDER), uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP), - dataHash = jsonObject.getString(DATA_HASH) + dataHash = jsonObject.getString(DATA_HASH_END) ) } } @@ -989,52 +1155,47 @@ class AttachmentTable( return readableDatabase.rawQuery(query, null) } - private fun deleteAttachmentOnDisk( - data: String?, + /** + * Deletes the data file if there's no strong references to other attachments. + * If deleted, it will also clear all weak references (i.e. quotes) of the attachment. + */ + private fun deleteDataFileIfPossible( + filePath: String?, contentType: String?, attachmentId: AttachmentId ) { check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - val dataUsage = getAttachmentFileUsages(data, attachmentId) - if (dataUsage.hasStrongReference) { - Log.i(TAG, "[deleteAttachmentOnDisk] Attachment in use. Skipping deletion. $data $attachmentId") + if (filePath == null) { + Log.w(TAG, "[deleteDataFileIfPossible] Null data file path for $attachmentId! Can't delete anything.") return } - Log.i(TAG, "[deleteAttachmentOnDisk] No other strong uses of this attachment. Safe to delete. $data $attachmentId") - if (!data.isNullOrBlank()) { - if (File(data).delete()) { - Log.i(TAG, "[deleteAttachmentOnDisk] Deleted attachment file. $data $attachmentId") + val strongReferenceExists = readableDatabase + .exists(TABLE_NAME) + .where("$DATA_FILE = ? AND QUOTE = 0 AND $ID != ${attachmentId.id}", filePath) + .run() - if (dataUsage.removableWeakReferences.isNotEmpty()) { - Log.i(TAG, "[deleteAttachmentOnDisk] Deleting ${dataUsage.removableWeakReferences.size} weak references for $data") + if (strongReferenceExists) { + Log.i(TAG, "[deleteDataFileIfPossible] Attachment in use. Skipping deletion of $attachmentId. Path: $filePath") + return + } - var deletedCount = 0 - for (weakReference in dataUsage.removableWeakReferences) { - Log.i(TAG, "[deleteAttachmentOnDisk] Clearing weak reference for $data $weakReference") + val weakReferenceCount = writableDatabase + .update(TABLE_NAME) + .values( + DATA_FILE to null, + DATA_RANDOM to null, + DATA_HASH_START to null, + DATA_HASH_END to null + ) + .where("$DATA_FILE = ?", filePath) + .run() - deletedCount += writableDatabase - .update(TABLE_NAME) - .values( - DATA_FILE to null, - DATA_RANDOM to null, - DATA_HASH to null - ) - .where("$ID = ?", weakReference.id) - .run() - } + Log.i(TAG, "[deleteDataFileIfPossible] Cleared $weakReferenceCount weak references for $attachmentId. Path: $filePath") - val logMessage = "[deleteAttachmentOnDisk] Cleared $deletedCount/${dataUsage.removableWeakReferences.size} weak references for $data" - if (deletedCount != dataUsage.removableWeakReferences.size) { - Log.w(TAG, logMessage) - } else { - Log.i(TAG, logMessage) - } - } - } else { - Log.w(TAG, "[deleteAttachmentOnDisk] Failed to delete attachment. $data $attachmentId") - } + if (!File(filePath).delete()) { + Log.w(TAG, "[deleteDataFileIfPossible] Failed to delete $attachmentId. Path: $filePath") } if (MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)) { @@ -1043,109 +1204,9 @@ class AttachmentTable( } } - private fun getAttachmentFileUsages(data: String?, attachmentId: AttachmentId): DataUsageResult { - check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - - if (data == null) { - return DataUsageResult.NOT_IN_USE - } - - val quoteRows: MutableList = mutableListOf() - - readableDatabase - .select(ID, QUOTE) - .from(TABLE_NAME) - .where("$DATA_FILE = ? AND $ID != ?", data, attachmentId.id) - .run() - .forEach { cursor -> - if (cursor.requireBoolean(QUOTE)) { - quoteRows += AttachmentId(cursor.requireLong(ID)) - } else { - return DataUsageResult.IN_USE - } - } - - return DataUsageResult(quoteRows) - } - - /** - * Check if data file is in use by another attachment row with a different hash. Rows with the same data and hash - * will be fixed in a later call to [updateAttachmentAndMatchingHashes]. - */ - private fun isAttachmentFileUsedByOtherAttachments(attachmentId: AttachmentId?, dataInfo: DataInfo): Boolean { - return if (attachmentId == null || dataInfo.hash == null) { - false - } else { - readableDatabase - .exists(TABLE_NAME) - .where("$DATA_FILE = ? AND $DATA_HASH != ?", dataInfo.file.absolutePath, dataInfo.hash) - .run() - } - } - - private fun updateAttachmentDataHash( - db: SQLiteDatabase, - oldHash: String?, - newData: DataInfo - ) { - if (oldHash == null) { - return - } - - db.update(TABLE_NAME) - .values( - DATA_FILE to newData.file.absolutePath, - DATA_RANDOM to newData.random, - DATA_HASH to newData.hash - ) - .where("$DATA_HASH = ?", oldHash) - .run() - } - - private fun updateAttachmentTransformProperties(attachmentId: AttachmentId, transformProperties: TransformProperties) { - val dataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) - if (dataInfo == null) { - Log.w(TAG, "[updateAttachmentTransformProperties] No data info found!") - return - } - - val contentValues = contentValuesOf(TRANSFORM_PROPERTIES to transformProperties.serialize()) - val updateCount = updateAttachmentAndMatchingHashes(databaseHelper.signalWritableDatabase, attachmentId, dataInfo.hash, contentValues) - Log.i(TAG, "[updateAttachmentTransformProperties] Updated $updateCount rows.") - } - - private fun updateAttachmentAndMatchingHashes( - db: SQLiteDatabase, - attachmentId: AttachmentId, - dataHash: String?, - contentValues: ContentValues - ): Int { - return db - .update(TABLE_NAME) - .values(contentValues) - .where("$ID = ? OR ($DATA_HASH NOT NULL AND $DATA_HASH = ?)", attachmentId.id, dataHash.toString()) - .run() - } - - /** - * Returns true if the file referenced by two or more attachments. - * Returns false if the file is referenced by zero or one attachments. - */ - private fun fileReferencedByMoreThanOneAttachment(file: File): Boolean { - return readableDatabase - .select("1") - .from(TABLE_NAME) - .where("$DATA_FILE = ?", file.absolutePath) - .limit(2) - .run() - .use { cursor -> - cursor.moveToNext() && cursor.moveToNext() - } - } - @Throws(FileNotFoundException::class) - private fun getDataStream(attachmentId: AttachmentId, dataType: String, offset: Long): InputStream? { - val dataInfo = getAttachmentDataFileInfo(attachmentId, dataType) ?: return null + private fun getDataStream(attachmentId: AttachmentId, offset: Long): InputStream? { + val dataInfo = getDataFileInfo(attachmentId) ?: return null return try { if (dataInfo.random.size == 32) { @@ -1162,15 +1223,6 @@ class AttachmentTable( } } - @Throws(MmsException::class) - private fun storeAttachmentStream(inputStream: InputStream): DataInfo { - return try { - storeAttachmentStream(newFile(context), inputStream) - } catch (e: IOException) { - throw MmsException(e) - } - } - @Throws(IOException::class) private fun newTransferFile(): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) @@ -1180,31 +1232,36 @@ class AttachmentTable( } /** - * Reads the entire stream and saves to disk. If you need to deduplicate attachments, call [deduplicateAttachment] - * afterwards and use the [DataInfo] returned by it instead. + * Reads the entire stream and saves to disk and returns a bunch of metadat about the write. */ @Throws(MmsException::class, IllegalStateException::class) - private fun storeAttachmentStream(destination: File, inputStream: InputStream): DataInfo { + private fun writeToDataFile(destination: File, inputStream: InputStream, transformProperties: TransformProperties): DataFileWriteResult { return try { - val tempFile = newFile(context) + // Sometimes the destination is a file that's already in use, sometimes it's not. + // To avoid writing to a file while it's in-use, we write to a temp file and then rename it to the destination file at the end. + val tempFile = newDataFile(context) val messageDigest = MessageDigest.getInstance("SHA-256") val digestInputStream = DigestInputStream(inputStream, messageDigest) - val out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) - val length = StreamUtil.copy(digestInputStream, out.second) - val hash = encodeWithPadding(digestInputStream.messageDigest.digest()) + + val encryptingStreamData = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) + val random = encryptingStreamData.first + val encryptingOutputStream = encryptingStreamData.second + + val length = StreamUtil.copy(digestInputStream, encryptingOutputStream) + val hash = Base64.encodeWithPadding(digestInputStream.messageDigest.digest()) if (!tempFile.renameTo(destination)) { - Log.w(TAG, "Couldn't rename ${tempFile.path} to ${destination.path}") + Log.w(TAG, "[writeToDataFile] Couldn't rename ${tempFile.path} to ${destination.path}") tempFile.delete() throw IllegalStateException("Couldn't rename ${tempFile.path} to ${destination.path}") } - DataInfo( + DataFileWriteResult( file = destination, length = length, - random = out.first, + random = random, hash = hash, - transformProperties = null + transformProperties = transformProperties ) } catch (e: IOException) { throw MmsException(e) @@ -1213,198 +1270,235 @@ class AttachmentTable( } } - private fun deduplicateAttachment( - dataInfo: DataInfo, - attachmentId: AttachmentId?, - transformProperties: TransformProperties? - ): DataInfo { - check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - - val sharedDataInfos = findDuplicateDataFileInfos(writableDatabase, dataInfo.hash, attachmentId) - - for (sharedDataInfo in sharedDataInfos) { - if (dataInfo.file == sharedDataInfo.file) { - continue + private fun areTransformationsCompatible( + newProperties: TransformProperties, + potentialMatchProperties: TransformProperties, + newHashStart: String, + potentialMatchHashEnd: String?, + newIsQuote: Boolean + ): Boolean { + // If we're starting now where another attachment finished, then it means we're forwarding an attachment. + if (newHashStart == potentialMatchHashEnd) { + // Quotes don't get transcoded or anything and are just a reference to the original attachment, so as long as the hashes match we're fine + if (newIsQuote) { + return true } - val isUsedElsewhere = isAttachmentFileUsedByOtherAttachments(attachmentId, dataInfo) - val isSameQuality = (transformProperties?.sentMediaQuality ?: 0) == (sharedDataInfo.transformProperties?.sentMediaQuality ?: 0) - - Log.i(TAG, "[deduplicateAttachment] Potential duplicate data file found. usedElsewhere: " + isUsedElsewhere + " sameQuality: " + isSameQuality + " otherFile: " + sharedDataInfo.file.absolutePath) - - if (!isSameQuality) { - continue + // If the new attachment is an edited video, we can't re-use the file + if (newProperties.videoEdited) { + return false } - if (!isUsedElsewhere) { - if (dataInfo.file.delete()) { - Log.i(TAG, "[deduplicateAttachment] Deleted original file. ${dataInfo.file}") - } else { - Log.w(TAG, "[deduplicateAttachment] Original file could not be deleted.") - } - } - - return sharedDataInfo + return true } - Log.i(TAG, "[deduplicateAttachment] No acceptable matching attachment data found. ${dataInfo.file.absolutePath}") - return dataInfo - } - - private fun findDuplicateDataFileInfos( - database: SQLiteDatabase, - hash: String?, - excludedAttachmentId: AttachmentId? - ): List { - check(database.inTransaction()) { "Must be in a transaction!" } - - if (hash == null) { - return emptyList() + if (newProperties.sentMediaQuality != potentialMatchProperties.sentMediaQuality) { + return false } - val selectorArgs: Pair> = buildSharedFileSelectorArgs(hash, excludedAttachmentId) - - return database - .select(DATA_FILE, DATA_RANDOM, DATA_SIZE, TRANSFORM_PROPERTIES) - .from(TABLE_NAME) - .where(selectorArgs.first, selectorArgs.second) - .run() - .readToList { cursor -> - DataInfo( - file = File(cursor.requireNonNullString(DATA_FILE)), - length = cursor.requireLong(DATA_SIZE), - random = cursor.requireNonNullBlob(DATA_RANDOM), - hash = hash, - transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)) - ) - } - } - - private fun buildSharedFileSelectorArgs(newHash: String, attachmentId: AttachmentId?): Pair> { - return if (attachmentId == null) { - "$DATA_HASH = ?" to arrayOf(newHash) - } else { - "$ID != ? AND $DATA_HASH = ?" to arrayOf( - attachmentId.id.toString(), - newHash - ) + if (newProperties.videoEdited != potentialMatchProperties.videoEdited) { + return false } + + if (newProperties.videoTrimStartTimeUs != potentialMatchProperties.videoTrimStartTimeUs) { + return false + } + + if (newProperties.videoTrimEndTimeUs != potentialMatchProperties.videoTrimEndTimeUs) { + return false + } + + if (newProperties.mp4FastStart != potentialMatchProperties.mp4FastStart) { + return false + } + + return true } + /** + * Attachments need records in the database even if they haven't been downloaded yet. That allows us to store the info we need to download it, what message + * it's associated with, etc. We treat this case separately from attachments with data (see [insertAttachmentWithData]) because it's much simpler, + * and splitting the two use cases makes the code easier to understand. + * + * Callers are expected to later call [finalizeAttachmentAfterDownload] once they have downloaded the data for this attachment. + */ @Throws(MmsException::class) - private fun insertAttachment(mmsId: Long, attachment: Attachment, quote: Boolean): AttachmentId { - Log.d(TAG, "Inserting attachment for mms id: $mmsId") - - var notifyPacks = false + private fun insertUndownloadedAttachment(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { + Log.d(TAG, "[insertAttachment] Inserting attachment for messageId $messageId.") val attachmentId: AttachmentId = writableDatabase.withinTransaction { db -> - try { - var dataInfo: DataInfo? = null - - if (attachment.uri != null) { - val storeDataInfo = storeAttachmentStream(PartAuthority.getAttachmentStream(context, attachment.uri!!)) - Log.d(TAG, "Wrote part to file: ${storeDataInfo.file.absolutePath}") - - dataInfo = deduplicateAttachment(storeDataInfo, null, attachment.transformProperties) - } - - var template = attachment - var useTemplateUpload = false - - if (dataInfo != null) { - val possibleTemplates = findTemplateAttachments(dataInfo.hash) - - for (possibleTemplate in possibleTemplates) { - useTemplateUpload = possibleTemplate.uploadTimestamp > attachment.uploadTimestamp && - possibleTemplate.transferState == TRANSFER_PROGRESS_DONE && - possibleTemplate.transformProperties?.shouldSkipTransform() == true && possibleTemplate.remoteDigest != null && - attachment.transformProperties?.videoEdited == false && possibleTemplate.transformProperties.sentMediaQuality == attachment.transformProperties.sentMediaQuality - - if (useTemplateUpload) { - Log.i(TAG, "Found a duplicate attachment upon insertion. Using it as a template.") - template = possibleTemplate - break - } - } - } - - val contentValues = ContentValues() - contentValues.put(MESSAGE_ID, mmsId) - contentValues.put(CONTENT_TYPE, template.contentType) - contentValues.put(TRANSFER_STATE, attachment.transferState) - contentValues.put(CDN_NUMBER, if (useTemplateUpload) template.cdnNumber else attachment.cdnNumber) - contentValues.put(REMOTE_LOCATION, if (useTemplateUpload) template.remoteLocation else attachment.remoteLocation) - contentValues.put(REMOTE_DIGEST, if (useTemplateUpload) template.remoteDigest else attachment.remoteDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST, if (useTemplateUpload) template.incrementalDigest else attachment.incrementalDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, if (useTemplateUpload) template.incrementalMacChunkSize else attachment.incrementalMacChunkSize) - contentValues.put(REMOTE_KEY, if (useTemplateUpload) template.remoteKey else attachment.remoteKey) - contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) - contentValues.put(DATA_SIZE, template.size) - contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) - contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) - contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) - contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) - contentValues.put(WIDTH, template.width) - contentValues.put(HEIGHT, template.height) - contentValues.put(QUOTE, quote) - contentValues.put(CAPTION, attachment.caption) - contentValues.put(UPLOAD_TIMESTAMP, if (useTemplateUpload) template.uploadTimestamp else attachment.uploadTimestamp) - - if (attachment.transformProperties?.videoEdited == true) { - contentValues.putNull(BLUR_HASH) - contentValues.put(TRANSFORM_PROPERTIES, attachment.transformProperties?.serialize()) - } else { - contentValues.put(BLUR_HASH, template.getVisualHashStringOrNull()) - contentValues.put(TRANSFORM_PROPERTIES, (if (useTemplateUpload) template else attachment).transformProperties?.serialize()) - } + val contentValues = ContentValues().apply { + put(MESSAGE_ID, messageId) + put(CONTENT_TYPE, attachment.contentType) + put(TRANSFER_STATE, attachment.transferState) + put(CDN_NUMBER, attachment.cdnNumber) + put(REMOTE_LOCATION, attachment.remoteLocation) + put(REMOTE_DIGEST, attachment.remoteDigest) + put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) + put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) + put(REMOTE_KEY, attachment.remoteKey) + put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) + put(DATA_SIZE, attachment.size) + put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) + put(VOICE_NOTE, attachment.voiceNote.toInt()) + put(BORDERLESS, attachment.borderless.toInt()) + put(VIDEO_GIF, attachment.videoGif.toInt()) + put(WIDTH, attachment.width) + put(HEIGHT, attachment.height) + put(QUOTE, quote) + put(CAPTION, attachment.caption) + put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) + put(BLUR_HASH, attachment.blurHash?.hash) attachment.stickerLocator?.let { sticker -> - contentValues.put(STICKER_PACK_ID, sticker.packId) - contentValues.put(STICKER_PACK_KEY, sticker.packKey) - contentValues.put(STICKER_ID, sticker.stickerId) - contentValues.put(STICKER_EMOJI, sticker.emoji) + put(STICKER_PACK_ID, sticker.packId) + put(STICKER_PACK_KEY, sticker.packKey) + put(STICKER_ID, sticker.stickerId) + put(STICKER_EMOJI, sticker.emoji) } - - if (dataInfo != null) { - contentValues.put(DATA_FILE, dataInfo.file.absolutePath) - contentValues.put(DATA_SIZE, dataInfo.length) - contentValues.put(DATA_RANDOM, dataInfo.random) - - if (attachment.transformProperties?.videoEdited == true) { - contentValues.putNull(DATA_HASH) - } else { - contentValues.put(DATA_HASH, dataInfo.hash) - } - } - - notifyPacks = attachment.isSticker && !hasStickerAttachments() - - val rowId = db.insert(TABLE_NAME, null, contentValues) - AttachmentId(rowId) - } catch (e: IOException) { - throw MmsException(e) } - } - if (notifyPacks) { - notifyStickerPackListeners() + val rowId = db.insert(TABLE_NAME, null, contentValues) + AttachmentId(rowId) } notifyAttachmentListeners() return attachmentId } - private fun findTemplateAttachments(dataHash: String?): List { - if (dataHash == null) { - return emptyList() + /** + * Inserts an attachment with existing data. This is likely an outgoing attachment that we're in the process of sending. + */ + @Throws(MmsException::class) + private fun insertAttachmentWithData(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { + requireNotNull(attachment.uri) { "Attachment must have a uri!" } + + Log.d(TAG, "[insertAttachmentWithData] Inserting attachment for messageId $messageId. (MessageId: $messageId, ${attachment.uri})") + + val dataStream = try { + PartAuthority.getAttachmentStream(context, attachment.uri!!) + } catch (e: IOException) { + throw MmsException(e) } - return readableDatabase - .select(*PROJECTION) - .from(TABLE_NAME) - .where("$DATA_HASH = ?", dataHash) - .run() - .readToList { it.readAttachment() } + // To avoid performing long-running operations in a transaction, we write the data to an independent file first in a way that doesn't rely on db state. + val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), dataStream, attachment.transformProperties ?: TransformProperties.empty()) + Log.d(TAG, "[insertAttachmentWithData] Wrote data to file: ${fileWriteResult.file.absolutePath} (MessageId: $messageId, ${attachment.uri})") + + val (attachmentId: AttachmentId, foundDuplicate: Boolean) = writableDatabase.withinTransaction { db -> + val contentValues = ContentValues() + var transformProperties = attachment.transformProperties ?: TransformProperties.empty() + + // First we'll check if our file hash matches the starting or ending hash of any other attachments and has compatible transform properties. + // We'll prefer the match with the most recent upload timestamp. + val hashMatch: DataFileInfo? = readableDatabase + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) + .from(TABLE_NAME) + .where("$DATA_FILE NOT NULL AND ($DATA_HASH_START = ? OR $DATA_HASH_END = ?)", fileWriteResult.hash, fileWriteResult.hash) + .run() + .readToList { it.readDataFileInfo() } + .sortedByDescending { it.uploadTimestamp } + .firstOrNull { existingMatch -> + areTransformationsCompatible( + newProperties = transformProperties, + potentialMatchProperties = existingMatch.transformProperties, + newHashStart = fileWriteResult.hash, + potentialMatchHashEnd = existingMatch.hashEnd, + newIsQuote = quote + ) + } + + if (hashMatch != null) { + if (fileWriteResult.hash == hashMatch.hashStart) { + Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment hash matches the DATA_HASH_START of ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") + } else if (fileWriteResult.hash == hashMatch.hashEnd) { + Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment hash matches the DATA_HASH_END of ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") + } else { + throw IllegalStateException("Should not be possible based on query.") + } + + contentValues.put(DATA_FILE, hashMatch.file.absolutePath) + contentValues.put(DATA_SIZE, hashMatch.length) + contentValues.put(DATA_RANDOM, hashMatch.random) + contentValues.put(DATA_HASH_START, fileWriteResult.hash) + contentValues.put(DATA_HASH_END, hashMatch.hashEnd) + + if (hashMatch.transformProperties.skipTransform) { + Log.i(TAG, "[insertAttachmentWithData] The hash match has a DATA_HASH_END and skipTransform=true, so skipping transform of the new file as well. (MessageId: $messageId, ${attachment.uri})") + transformProperties = transformProperties.copy(skipTransform = true) + } + } else { + Log.i(TAG, "[insertAttachmentWithData] No matching hash found. (MessageId: $messageId, ${attachment.uri})") + contentValues.put(DATA_FILE, fileWriteResult.file.absolutePath) + contentValues.put(DATA_SIZE, fileWriteResult.length) + contentValues.put(DATA_RANDOM, fileWriteResult.random) + contentValues.put(DATA_HASH_START, fileWriteResult.hash) + } + + // Our hashMatch already represents a transform-compatible attachment with the most recent upload timestamp. We just need to make sure it has all of the + // other necessary fields, and if so, we can use that to skip the upload. + var uploadTemplate: Attachment? = null + if (hashMatch?.hashEnd != null && System.currentTimeMillis() - hashMatch.uploadTimestamp < AttachmentUploadJob.UPLOAD_REUSE_THRESHOLD) { + uploadTemplate = readableDatabase + .select(*PROJECTION) + .from(TABLE_NAME) + .where("$ID = ${hashMatch.id.id} AND $REMOTE_DIGEST NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_HASH_END NOT NULL") + .run() + .readToSingleObject { it.readAttachment() } + } + + if (uploadTemplate != null) { + Log.i(TAG, "[insertAttachmentWithData] Found a valid template we could use to skip upload. (MessageId: $messageId, ${attachment.uri})") + transformProperties = (uploadTemplate.transformProperties ?: transformProperties).copy(skipTransform = true) + } + + contentValues.put(MESSAGE_ID, messageId) + contentValues.put(CONTENT_TYPE, uploadTemplate?.contentType ?: attachment.contentType) + contentValues.put(TRANSFER_STATE, attachment.transferState) // Even if we have a template, we let AttachmentUploadJob have the final say so it can re-check and make sure the template is still valid + contentValues.put(CDN_NUMBER, uploadTemplate?.cdnNumber ?: 0) + contentValues.put(REMOTE_LOCATION, uploadTemplate?.remoteLocation) + contentValues.put(REMOTE_DIGEST, uploadTemplate?.remoteDigest) + contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate?.incrementalDigest) + contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate?.incrementalMacChunkSize ?: 0) + contentValues.put(REMOTE_KEY, uploadTemplate?.remoteKey) + contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) + contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) + contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) + contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) + contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) + contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) + contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) + contentValues.put(QUOTE, quote) + contentValues.put(CAPTION, attachment.caption) + contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0) + contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) + + if (attachment.transformProperties?.videoEdited == true) { + contentValues.putNull(BLUR_HASH) + } else { + contentValues.put(BLUR_HASH, uploadTemplate.getVisualHashStringOrNull()) + } + + attachment.stickerLocator?.let { sticker -> + contentValues.put(STICKER_PACK_ID, sticker.packId) + contentValues.put(STICKER_PACK_KEY, sticker.packKey) + contentValues.put(STICKER_ID, sticker.stickerId) + contentValues.put(STICKER_EMOJI, sticker.emoji) + } + + val rowId = db.insert(TABLE_NAME, null, contentValues) + + AttachmentId(rowId) to (hashMatch != null) + } + + if (foundDuplicate) { + if (!fileWriteResult.file.delete()) { + Log.w(TAG, "[insertAttachmentWithData] Failed to delete duplicate file: ${fileWriteResult.file.absolutePath}") + } + } + + notifyAttachmentListeners() + return attachmentId } private fun getTransferFile(db: SQLiteDatabase, attachmentId: AttachmentId): File? { @@ -1451,7 +1545,7 @@ class AttachmentTable( transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), displayOrder = cursor.requireInt(DISPLAY_ORDER), uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP), - dataHash = cursor.requireString(DATA_HASH) + dataHash = cursor.requireString(DATA_HASH_END) ) } @@ -1463,6 +1557,19 @@ class AttachmentTable( return getAttachment(this) } + private fun Cursor.readDataFileInfo(): DataFileInfo { + return DataFileInfo( + id = AttachmentId(this.requireLong(ID)), + file = File(this.requireNonNullString(DATA_FILE)), + length = this.requireLong(DATA_SIZE), + random = this.requireNonNullBlob(DATA_RANDOM), + hashStart = this.requireString(DATA_HASH_START), + hashEnd = this.requireString(DATA_HASH_END), + transformProperties = TransformProperties.parse(this.requireString(TRANSFORM_PROPERTIES)), + uploadTimestamp = this.requireLong(UPLOAD_TIMESTAMP) + ) + } + private fun Cursor.readStickerLocator(): StickerLocator? { return if (this.requireInt(STICKER_ID) >= 0) { StickerLocator( @@ -1479,8 +1586,8 @@ class AttachmentTable( private fun Attachment?.getVisualHashStringOrNull(): String? { return when { this == null -> null - this.blurHash != null -> this.blurHash!!.hash - this.audioHash != null -> this.audioHash!!.hash + this.blurHash != null -> this.blurHash.hash + this.audioHash != null -> this.audioHash.hash else -> null } } @@ -1489,7 +1596,7 @@ class AttachmentTable( return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) - .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH IS NOT NULL") + .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH_END IS NOT NULL") .orderBy("$ID DESC") .limit(30) .run() @@ -1497,81 +1604,52 @@ class AttachmentTable( .flatten() } - @VisibleForTesting - class DataInfo( + class DataFileWriteResult( val file: File, val length: Long, val random: ByteArray, - val hash: String?, - val transformProperties: TransformProperties? - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + val hash: String, + val transformProperties: TransformProperties + ) - other as DataInfo - - if (file != other.file) return false - if (length != other.length) return false - if (!random.contentEquals(other.random)) return false - if (hash != other.hash) return false - return transformProperties == other.transformProperties - } - - override fun hashCode(): Int { - var result = file.hashCode() - result = 31 * result + length.hashCode() - result = 31 * result + random.contentHashCode() - result = 31 * result + (hash?.hashCode() ?: 0) - result = 31 * result + (transformProperties?.hashCode() ?: 0) - return result - } - } - - /** - * @param removableWeakReferences Entries in here can be removed from the database. Only possible to be non-empty when [hasStrongReference] is false. - */ - private class DataUsageResult private constructor(val hasStrongReference: Boolean, val removableWeakReferences: List) { - constructor(removableWeakReferences: List) : this(false, removableWeakReferences) - - init { - if (hasStrongReference && removableWeakReferences.isNotEmpty()) { - throw IllegalStateException("There's a strong reference and removable weak references!") - } - } - - companion object { - val IN_USE = DataUsageResult(true, emptyList()) - val NOT_IN_USE = DataUsageResult(false, emptyList()) - } - } + @VisibleForTesting + class DataFileInfo( + val id: AttachmentId, + val file: File, + val length: Long, + val random: ByteArray, + val hashStart: String?, + val hashEnd: String?, + val transformProperties: TransformProperties, + val uploadTimestamp: Long + ) @Parcelize data class TransformProperties( @JsonProperty("skipTransform") @JvmField - val skipTransform: Boolean, + val skipTransform: Boolean = false, @JsonProperty("videoTrim") @JvmField - val videoTrim: Boolean, + val videoTrim: Boolean = false, @JsonProperty("videoTrimStartTimeUs") @JvmField - val videoTrimStartTimeUs: Long, + val videoTrimStartTimeUs: Long = 0, @JsonProperty("videoTrimEndTimeUs") @JvmField - val videoTrimEndTimeUs: Long, + val videoTrimEndTimeUs: Long = 0, @JsonProperty("sentMediaQuality") @JvmField - val sentMediaQuality: Int, + val sentMediaQuality: Int = SentMediaQuality.STANDARD.code, @field:JsonProperty("mp4Faststart") @param:JsonProperty("mp4Faststart") @JvmField - val mp4FastStart: Boolean + val mp4FastStart: Boolean = false ) : Parcelable { fun shouldSkipTransform(): Boolean { return skipTransform @@ -1583,11 +1661,7 @@ class AttachmentTable( fun withSkipTransform(): TransformProperties { return this.copy( - skipTransform = true, - videoTrim = false, - videoTrimStartTimeUs = 0, - videoTrimEndTimeUs = 0, - mp4FastStart = false + skipTransform = true ) } @@ -1642,6 +1716,11 @@ class AttachmentTable( return existing.copy(sentMediaQuality = sentMediaQuality.code) } + @JvmStatic + fun forSentMediaQuality(sentMediaQuality: Int): TransformProperties { + return TransformProperties(sentMediaQuality = sentMediaQuality) + } + @JvmStatic fun parse(serialized: String?): TransformProperties { return if (serialized == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 76f6c63258..9dc0994db2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -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 ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index 65d73b54d9..600a746024 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 9788db5315..d3f6da95ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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, proposedExpireStarted: Long, threadToLatestRead: MutableMap): Collection { + fun setTimestampReadFromSyncMessage(readMessages: List, proposedExpireStarted: Long, threadToLatestRead: MutableMap): Collection { val expiringMessages: MutableList> = mutableListOf() val updatedThreads: MutableSet = mutableSetOf() val unhandled: MutableCollection = 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, proposedExpireStarted: Long, threadToLatestRead: MutableMap): Collection { - val reads: List = 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 4012da88f7..1e22a6df36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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()))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt index edc29af53e..b86b6e6ec2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -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) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 4cb0c9456f..fb44a1099e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt new file mode 100644 index 0000000000..02e39ceec0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt @@ -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)") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V223_AddNicknameAndNoteFieldsToRecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V223_AddNicknameAndNoteFieldsToRecipientTable.kt new file mode 100644 index 0000000000..4dbfea54c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V223_AddNicknameAndNoteFieldsToRecipientTable.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java index 71a1083e67..e27fd7df3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java @@ -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> { 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 { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index e84973e0ad..55f2bdf93f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -234,15 +234,24 @@ final class GroupsV2UpdateMessageProducer { } } private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List 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)); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index 3830afc481..72ddce8e81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java index c04a2c3214..5aefdab7d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java @@ -85,13 +85,13 @@ public class DeleteAccountFragment extends Fragment { } private @NonNull CharSequence buildBulletsText(@NonNull Optional 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt new file mode 100644 index 0000000000..4619dd41b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt new file mode 100644 index 0000000000..d04ca5c2cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt @@ -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() + + @Composable + override fun SheetContent() { + var selectedOption by remember { + mutableStateOf(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 + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt new file mode 100644 index 0000000000..ea34c70d1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java index ec9f9733f0..257990f890 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java @@ -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(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt new file mode 100644 index 0000000000..7848ff2304 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java index 948dddf52d..49ca457e38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java @@ -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)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt index 9e9180db2f..058714ee55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java index b7ff181680..ec4a08d7a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobExtensions.kt new file mode 100644 index 0000000000..525b7b4e9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobExtensions.kt @@ -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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index ffbcfb6be0..1968050b23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -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()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java index 56d3049ba4..bd4d619e4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentHashBackfillJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentHashBackfillJob.kt new file mode 100644 index 0000000000..a4178f470c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentHashBackfillJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): AttachmentHashBackfillJob { + return AttachmentHashBackfillJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt index c2d5ad5dff..e8af9f7247 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLogEventSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLogEventSendJob.kt index 992b76f93f..b087aa260a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLogEventSendJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLogEventSendJob.kt @@ -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 ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 1590353ce1..ae70792400 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java index fe03637f24..0aa52d93aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt new file mode 100644 index 0000000000..78d573535d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): LinkedDeviceInactiveCheckJob { + return LinkedDeviceInactiveCheckJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt index 59fbd5c3dd..23f8c4f1f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java index 721c06a87a..f0c6663845 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -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()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java index 5b3082b05a..511cea5064 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java @@ -84,7 +84,6 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob { Optional.empty(), Optional.empty(), profileKey, - false, Optional.empty(), Optional.empty(), false)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt index 27c52d36bc..2c9f6b3d78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PnpInitializeDevicesJob.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt index 2f216142fe..1e222b937f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PreKeysSyncJob.kt @@ -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 { 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) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index f13cfa2390..8580aaad0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -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 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java index 232c34437f..545df2360d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java index 8e882f7dee..ae727e135d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index 4808dc932b..2bf269d5a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -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 getAllLocalStorageIds(@NonNull Recipient self) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java deleted file mode 100644 index de9b13beca..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ /dev/null @@ -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 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); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt new file mode 100644 index 0000000000..d1aeeec4aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -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 { + 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt index 99d586a802..cf4c68af57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt @@ -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 { + return NullableBlobValue(key, default, this.store) +} + internal fun SignalStoreValues.enumValue(key: String, default: T, serializer: LongSerializer): SignalStoreValueDelegate { 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(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( private val key: String, private val adapter: ProtoAdapter, diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java index 5f0cb40624..2ae8c604e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java @@ -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"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 2d19f0ae17..25be8d873b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -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); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java index fd516e2861..28351a505a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java index b387698931..93fce43023 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java @@ -19,14 +19,13 @@ import androidx.camera.video.Recording; import androidx.camera.video.VideoRecordEvent; import androidx.camera.view.PreviewView; import androidx.camera.view.video.AudioConfig; +import androidx.core.content.ContextCompat; import androidx.core.util.Consumer; import androidx.fragment.app.Fragment; -import com.bumptech.glide.util.Executors; - import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXController; import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.ContextUtil; @@ -49,7 +48,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener private final @NonNull Fragment fragment; private final @NonNull PreviewView previewView; - private final @NonNull SignalCameraController cameraController; + private final @NonNull CameraXController cameraController; private final @NonNull Callback callback; private final @NonNull MemoryFileDescriptor memoryFileDescriptor; private final @NonNull ValueAnimator updateProgressAnimator; @@ -88,7 +87,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener CameraXVideoCaptureHelper(@NonNull Fragment fragment, @NonNull CameraButtonView captureButton, - @NonNull SignalCameraController cameraController, + @NonNull CameraXController cameraController, @NonNull PreviewView previewView, @NonNull MemoryFileDescriptor memoryFileDescriptor, @NonNull CameraXModePolicy cameraXModePolicy, @@ -150,7 +149,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener FileDescriptorOutputOptions outputOptions = new FileDescriptorOutputOptions.Builder(memoryFileDescriptor.getParcelFileDescriptor()).build(); AudioConfig audioConfig = AudioConfig.create(true); - activeRecording = cameraController.startRecording(outputOptions, audioConfig, videoSavedListener); + activeRecording = cameraController.startRecording(outputOptions, audioConfig, ContextCompat.getMainExecutor(fragment.requireContext()), videoSavedListener); updateProgressAnimator.start(); debouncer.publish(this::onVideoCaptureComplete); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java index 7dd440a3aa..2d3b6bfe7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java @@ -108,6 +108,7 @@ public class MediaUploadRepository { } public void cancelUpload(@NonNull Collection mediaItems) { + Log.d(TAG, "Canceling uploads."); executor.execute(() -> { for (Media media : mediaItems) { cancelUploadInternal(media); @@ -116,6 +117,7 @@ public class MediaUploadRepository { } public void cancelAllUploads() { + Log.d(TAG, "Canceling all uploads."); executor.execute(() -> { for (Media media : new HashSet<>(uploadResults.keySet())) { cancelUploadInternal(media); @@ -159,6 +161,7 @@ public class MediaUploadRepository { PreUploadResult result = uploadResults.get(media); if (result != null) { + Log.d(TAG, "Canceling upload jobs for " + result.getJobIds().size() + " media items."); Stream.of(result.getJobIds()).forEach(jobManager::cancel); uploadResults.remove(media); SignalDatabase.attachments().deleteAttachment(result.getAttachmentId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt index 1e2fe49e83..ae87d4241b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoEditorFragment.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.video.VideoPlayer.PlayerCallback import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView.PositionDragListener import java.io.IOException +import kotlin.time.Duration.Companion.microseconds class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragment { private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() }) @@ -82,7 +83,6 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm isVideoGif = requireArguments().getBoolean(KEY_IS_VIDEO_GIF) maxSend = requireArguments().getLong(KEY_MAX_SEND) - val state = sharedViewModel.state.value!! val slide = VideoSlide(requireContext(), uri, 0, isVideoGif) player.setWindow(requireActivity().window) player.setVideoSource(slide, isVideoGif, TAG) @@ -103,7 +103,9 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm player.loopForever() } else { if (MediaConstraints.isVideoTranscodeAvailable()) { - bindVideoTimeline(state) + sharedViewModel.state.value?.let { state -> + bindVideoTimeline(state) + } } else { hud.visibility = View.VISIBLE } @@ -176,7 +178,7 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm if (slide.hasVideo()) { canEdit = true try { - videoTimeLine.registerPlayerOnRangeChangeListener(this) + videoTimeLine.registerPlayerDragListener(this) hud.visibility = View.VISIBLE startPositionUpdates() @@ -293,7 +295,8 @@ class VideoEditorFragment : Fragment(), PositionDragListener, MediaSendPageFragm videoScanThrottle.publish { player.pause() - player.playbackPosition = position + val milliseconds = position.microseconds.inWholeMilliseconds + player.playbackPosition = milliseconds } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXController.kt new file mode 100644 index 0000000000..a2fe406498 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXController.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.camerax + +import android.Manifest +import android.content.Context +import android.util.Size +import androidx.annotation.MainThread +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ZoomState +import androidx.camera.video.FileDescriptorOutputOptions +import androidx.camera.video.Recording +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.video.AudioConfig +import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import com.google.common.util.concurrent.ListenableFuture +import java.util.concurrent.Executor + +interface CameraXController { + + fun isInitialized(): Boolean + + fun initializeAndBind(context: Context, lifecycleOwner: LifecycleOwner) + + @RequiresPermission(Manifest.permission.CAMERA) + fun bindToLifecycle(lifecycleOwner: LifecycleOwner, onCameraBoundListener: Runnable) + + @MainThread + fun unbind() + + @MainThread + fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) + + @RequiresApi(26) + @MainThread + fun startRecording(outputOptions: FileDescriptorOutputOptions, audioConfig: AudioConfig, executor: Executor, videoSavedListener: Consumer): Recording + + @MainThread + fun setImageAnalysisAnalyzer(executor: Executor, analyzer: ImageAnalysis.Analyzer) + + @MainThread + fun setEnabledUseCases(useCaseFlags: Int) + + @MainThread + fun getImageCaptureFlashMode(): Int + + @MainThread + fun setPreviewTargetSize(size: Size) + + @MainThread + fun setImageCaptureTargetSize(size: Size) + + @MainThread + fun setImageRotation(rotation: Int) + + @MainThread + fun setImageCaptureFlashMode(flashMode: Int) + + @MainThread + fun setZoomRatio(ratio: Float): ListenableFuture + + @MainThread + fun getZoomState(): LiveData + + @MainThread + fun setCameraSelector(selector: CameraSelector) + + @MainThread + fun getCameraSelector(): CameraSelector + + @MainThread + fun hasCamera(selectedCamera: CameraSelector): Boolean + + @MainThread + fun addInitializationCompletedListener(executor: Executor, onComplete: () -> Unit) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt index cad9cb9bed..23a71f8d80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt @@ -15,11 +15,11 @@ sealed class CameraXModePolicy { abstract val isVideoSupported: Boolean - abstract fun initialize(cameraController: SignalCameraController) + abstract fun initialize(cameraController: CameraXController) - open fun setToImage(cameraController: SignalCameraController) = Unit + open fun setToImage(cameraController: CameraXController) = Unit - open fun setToVideo(cameraController: SignalCameraController) = Unit + open fun setToVideo(cameraController: CameraXController) = Unit /** * The device supports having Image and Video enabled at the same time @@ -28,7 +28,7 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = true - override fun initialize(cameraController: SignalCameraController) { + override fun initialize(cameraController: CameraXController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE) } } @@ -40,15 +40,15 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = true - override fun initialize(cameraController: SignalCameraController) { + override fun initialize(cameraController: CameraXController) { setToImage(cameraController) } - override fun setToImage(cameraController: SignalCameraController) { + override fun setToImage(cameraController: CameraXController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) } - override fun setToVideo(cameraController: SignalCameraController) { + override fun setToVideo(cameraController: CameraXController) { cameraController.setEnabledUseCases(CameraController.VIDEO_CAPTURE) } } @@ -60,7 +60,7 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = false - override fun initialize(cameraController: SignalCameraController) { + override fun initialize(cameraController: CameraXController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PlatformCameraController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PlatformCameraController.kt new file mode 100644 index 0000000000..9223160b2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PlatformCameraController.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.camerax + +import android.content.Context +import android.util.Size +import androidx.annotation.RequiresApi +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ZoomState +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileDescriptorOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recording +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.video.AudioConfig +import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import com.google.common.util.concurrent.ListenableFuture +import org.thoughtcrime.securesms.util.TextSecurePreferences +import java.util.concurrent.Executor + +class PlatformCameraController(context: Context) : CameraXController { + val delegate = LifecycleCameraController(context) + + override fun isInitialized(): Boolean { + return delegate.initializationFuture.isDone + } + + override fun initializeAndBind(context: Context, lifecycleOwner: LifecycleOwner) { + delegate.bindToLifecycle(lifecycleOwner) + delegate.setCameraSelector(CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(context))) + delegate.setTapToFocusEnabled(true) + delegate.setImageCaptureMode(CameraXUtil.getOptimalCaptureMode()) + delegate.setVideoCaptureQualitySelector(QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityThan(Quality.HD))) + } + + override fun bindToLifecycle(lifecycleOwner: LifecycleOwner, onCameraBoundListener: Runnable) { + delegate.bindToLifecycle(lifecycleOwner) + onCameraBoundListener.run() + } + + override fun unbind() { + delegate.unbind() + } + + override fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) { + delegate.takePicture(executor, callback) + } + + @RequiresApi(26) + override fun startRecording(outputOptions: FileDescriptorOutputOptions, audioConfig: AudioConfig, executor: Executor, videoSavedListener: Consumer): Recording { + return delegate.startRecording(outputOptions, audioConfig, executor, videoSavedListener) + } + + override fun setImageAnalysisAnalyzer(executor: Executor, analyzer: ImageAnalysis.Analyzer) { + delegate.setImageAnalysisAnalyzer(executor, analyzer) + } + + override fun setEnabledUseCases(useCaseFlags: Int) { + delegate.setEnabledUseCases(useCaseFlags) + } + + override fun getImageCaptureFlashMode(): Int { + return delegate.imageCaptureFlashMode + } + + override fun setPreviewTargetSize(size: Size) { + delegate.previewTargetSize = CameraController.OutputSize(size) + } + + override fun setImageCaptureTargetSize(size: Size) { + delegate.imageCaptureTargetSize = CameraController.OutputSize(size) + } + + override fun setImageRotation(rotation: Int) { + throw NotImplementedError("Not supported by the platform camera controller!") + } + + override fun setImageCaptureFlashMode(flashMode: Int) { + delegate.imageCaptureFlashMode = flashMode + } + + override fun setZoomRatio(ratio: Float): ListenableFuture { + return delegate.setZoomRatio(ratio) + } + + override fun getZoomState(): LiveData { + return delegate.zoomState + } + + override fun setCameraSelector(selector: CameraSelector) { + delegate.cameraSelector = selector + } + + override fun getCameraSelector(): CameraSelector { + return delegate.cameraSelector + } + + override fun hasCamera(selectedCamera: CameraSelector): Boolean { + return delegate.hasCamera(selectedCamera) + } + + override fun addInitializationCompletedListener(executor: Executor, onComplete: () -> Unit) { + delegate.initializationFuture.addListener(onComplete, executor) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt index 9f2f31d534..83dde1db0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt @@ -21,15 +21,17 @@ import androidx.camera.core.CameraSelector import androidx.camera.core.DisplayOrientedMeteringPointFactory import androidx.camera.core.FocusMeteringAction import androidx.camera.core.FocusMeteringResult +import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture import androidx.camera.core.Preview import androidx.camera.core.UseCase import androidx.camera.core.UseCaseGroup -import androidx.camera.core.ViewPort import androidx.camera.core.ZoomState import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.extensions.ExtensionMode +import androidx.camera.extensions.ExtensionsManager import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.FallbackStrategy import androidx.camera.video.FileDescriptorOutputOptions @@ -51,6 +53,7 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import org.signal.core.util.ThreadUtil import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController.InitializationListener import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.visible @@ -68,7 +71,7 @@ class SignalCameraController( private val lifecycleOwner: LifecycleOwner, private val previewView: PreviewView, private val focusIndicator: View -) { +) : CameraXController { companion object { val TAG = Log.tag(SignalCameraController::class.java) @@ -86,11 +89,11 @@ class SignalCameraController( private val cameraProviderFuture: ListenableFuture = ProcessCameraProvider.getInstance(context) private val scaleGestureDetector = ScaleGestureDetector(context, PinchToZoomGestureListener()) - private val viewPort: ViewPort? = previewView.getViewPort(Surface.ROTATION_0) private val initializationCompleteListeners: MutableSet = mutableSetOf() private val customUseCases: MutableList = mutableListOf() private var tapToFocusEvents = 0 + private var listenerAdded = false private var imageRotation = 0 private var recording: Recording? = null @@ -104,41 +107,68 @@ class SignalCameraController( private var videoCaptureUseCase: VideoCapture = createVideoCaptureRecorder() private lateinit var cameraProvider: ProcessCameraProvider + private lateinit var extensionsManager: ExtensionsManager private lateinit var cameraProperty: Camera + override fun isInitialized(): Boolean { + return this::cameraProvider.isInitialized && this::extensionsManager.isInitialized + } + + override fun initializeAndBind(context: Context, lifecycleOwner: LifecycleOwner) { + bindToLifecycle(lifecycleOwner) { Log.d(TAG, "Camera initialization and binding complete.") } + } + @RequiresPermission(Manifest.permission.CAMERA) - fun bindToLifecycle(onCameraBoundListener: Runnable) { + override fun bindToLifecycle(lifecycleOwner: LifecycleOwner, onCameraBoundListener: Runnable) { ThreadUtil.assertMainThread() - if (this::cameraProvider.isInitialized) { + if (isInitialized()) { bindToLifecycleInternal() onCameraBoundListener.run() - } else { + } else if (!listenerAdded) { cameraProviderFuture.addListener({ cameraProvider = cameraProviderFuture.get() - initializationCompleteListeners.forEach { it.onInitialized(cameraProvider) } - bindToLifecycleInternal() - onCameraBoundListener.run() + val extensionsManagerFuture = + ExtensionsManager.getInstanceAsync(context, cameraProvider) + extensionsManagerFuture.addListener({ + extensionsManager = extensionsManagerFuture.get() + initializationCompleteListeners.forEach { it.onInitialized(cameraProvider) } + bindToLifecycleInternal() + onCameraBoundListener.run() + }, ContextCompat.getMainExecutor(context)) }, ContextCompat.getMainExecutor(context)) + listenerAdded = true } } @MainThread - fun unbind() { + override fun unbind() { ThreadUtil.assertMainThread() cameraProvider.unbindAll() } @MainThread - private fun bindToLifecycleInternal() { + fun bindToLifecycleInternal() { ThreadUtil.assertMainThread() try { - if (!this::cameraProvider.isInitialized) { + if (!this::cameraProvider.isInitialized || !this::extensionsManager.isInitialized) { Log.d(TAG, "Camera provider not yet initialized.") return } + + val extCameraSelector = if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.AUTO)) { + Log.d(TAG, "Using CameraX ExtensionMode.AUTO") + extensionsManager.getExtensionEnabledCameraSelector( + cameraSelector, + ExtensionMode.AUTO + ) + } else { + Log.d(TAG, "Using standard camera selector") + cameraSelector + } + val camera = cameraProvider.bindToLifecycle( lifecycleOwner, - cameraSelector, + extCameraSelector, buildUseCaseGroup() ) @@ -150,10 +180,16 @@ class SignalCameraController( } @MainThread - fun addUseCase(useCase: UseCase) { + override fun setImageAnalysisAnalyzer(executor: Executor, analyzer: ImageAnalysis.Analyzer) { ThreadUtil.assertMainThread() + val imageAnalysis = ImageAnalysis.Builder() + .setResolutionSelector(ResolutionSelector.Builder().setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY).build()) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() - customUseCases += useCase + imageAnalysis.setAnalyzer(executor, analyzer) + + customUseCases += imageAnalysis if (isRecording()) { stopRecording() @@ -163,7 +199,7 @@ class SignalCameraController( } @MainThread - fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) { + override fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) { ThreadUtil.assertMainThread() assertImageEnabled() imageCaptureUseCase.takePicture(executor, callback) @@ -171,7 +207,7 @@ class SignalCameraController( @RequiresApi(26) @MainThread - fun startRecording(outputOptions: FileDescriptorOutputOptions, audioConfig: AudioConfig, videoSavedListener: Consumer): Recording { + override fun startRecording(outputOptions: FileDescriptorOutputOptions, audioConfig: AudioConfig, executor: Executor, videoSavedListener: Consumer): Recording { ThreadUtil.assertMainThread() assertVideoEnabled() @@ -196,7 +232,7 @@ class SignalCameraController( } @MainThread - fun setEnabledUseCases(useCaseFlags: Int) { + override fun setEnabledUseCases(useCaseFlags: Int) { ThreadUtil.assertMainThread() if (enabledUseCases == useCaseFlags) { return @@ -211,13 +247,13 @@ class SignalCameraController( } @MainThread - fun getImageCaptureFlashMode(): Int { + override fun getImageCaptureFlashMode(): Int { ThreadUtil.assertMainThread() return imageCaptureUseCase.flashMode } @MainThread - fun setPreviewTargetSize(size: Size) { + override fun setPreviewTargetSize(size: Size) { ThreadUtil.assertMainThread() if (size == previewTargetSize || previewTargetSize?.equals(size) == true) { return @@ -233,7 +269,7 @@ class SignalCameraController( } @MainThread - fun setImageCaptureTargetSize(size: Size) { + override fun setImageCaptureTargetSize(size: Size) { ThreadUtil.assertMainThread() if (size == imageCaptureTargetSize || imageCaptureTargetSize?.equals(size) == true) { return @@ -247,7 +283,7 @@ class SignalCameraController( } @MainThread - fun setImageRotation(rotation: Int) { + override fun setImageRotation(rotation: Int) { ThreadUtil.assertMainThread() val newRotation = UseCase.snapToSurfaceRotation(rotation.coerceIn(0, 359)) @@ -266,25 +302,25 @@ class SignalCameraController( } @MainThread - fun setImageCaptureFlashMode(flashMode: Int) { + override fun setImageCaptureFlashMode(flashMode: Int) { ThreadUtil.assertMainThread() imageCaptureUseCase.flashMode = flashMode } @MainThread - fun setZoomRatio(ratio: Float): ListenableFuture { + override fun setZoomRatio(ratio: Float): ListenableFuture { ThreadUtil.assertMainThread() return cameraProperty.cameraControl.setZoomRatio(ratio) } @MainThread - fun getZoomState(): LiveData { + override fun getZoomState(): LiveData { ThreadUtil.assertMainThread() return cameraProperty.cameraInfo.zoomState } @MainThread - fun setCameraSelector(selector: CameraSelector) { + override fun setCameraSelector(selector: CameraSelector) { ThreadUtil.assertMainThread() if (selector == cameraSelector) { return @@ -300,21 +336,20 @@ class SignalCameraController( } @MainThread - fun getCameraSelector(): CameraSelector { + override fun getCameraSelector(): CameraSelector { ThreadUtil.assertMainThread() return cameraSelector } @MainThread - fun hasCamera(selectedCamera: CameraSelector): Boolean { + override fun hasCamera(selectedCamera: CameraSelector): Boolean { ThreadUtil.assertMainThread() return cameraProvider.hasCamera(selectedCamera) } - @MainThread - fun addInitializationCompletedListener(listener: InitializationListener) { + override fun addInitializationCompletedListener(executor: Executor, onComplete: () -> Unit) { ThreadUtil.assertMainThread() - initializationCompleteListeners.add(listener) + initializationCompleteListeners.add(InitializationListener { onComplete() }) } @MainThread @@ -391,11 +426,7 @@ class SignalCameraController( addUseCase(useCase) } - if (viewPort != null) { - setViewPort(viewPort) - } else { - Log.d(TAG, "ViewPort was null, not adding to UseCase builder.") - } + previewView.getViewPort(Surface.ROTATION_0)?.let { setViewPort(it) } ?: Log.d(TAG, "ViewPort was null, not adding to UseCase builder.") }.build() @MainThread @@ -503,7 +534,7 @@ class SignalCameraController( override fun onScaleEnd(detector: ScaleGestureDetector) = Unit } - interface InitializationListener { + fun interface InitializationListener { fun onInitialized(cameraProvider: ProcessCameraProvider) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index a16619d96c..84529d23e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.animation.ValueAnimator import android.content.Context import android.content.Intent +import android.content.pm.ActivityInfo import android.graphics.Color import android.os.Bundle import android.view.KeyEvent @@ -43,6 +44,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.Debouncer +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.FullscreenHelper import org.thoughtcrime.securesms.util.WindowUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -90,6 +92,10 @@ class MediaSelectionActivity : override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { setContentView(R.layout.media_selection_activity) + if (FeatureFlags.customCameraXController()) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + FullscreenHelper.showSystemUI(window) WindowUtil.setNavigationBarColor(this, 0x01000000) WindowUtil.setStatusBarColor(window, Color.TRANSPARENT) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 9796331d93..bd711932f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -344,7 +344,7 @@ class MediaSelectionViewModel( store.update { val uri = it.focusedMedia?.uri ?: return@update it val data = it.getOrCreateVideoTrimData(uri) - val clampedStartTime = max(startTimeUs.toDouble(), 0.0).toLong() + val clampedStartTime = max(startTimeUs, 0) val unedited = !data.isDurationEdited val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs @@ -356,8 +356,15 @@ class MediaSelectionViewModel( it.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(getMediaConstraints().getVideoMaxSize(context)).seconds.inWholeMicroseconds } val preserveStartTime = unedited || !endMoved - val updatedData = clampToMaxClipDuration(VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs), maxVideoDurationUs, preserveStartTime) + val videoTrimData = VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs) + val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime) + + if (updatedData != videoTrimData) { + Log.d(TAG, "Video trim clamped from ${videoTrimData.startTimeUs}, ${videoTrimData.endTimeUs} to ${updatedData.startTimeUs}, ${updatedData.endTimeUs}") + } + if (unedited && durationEdited) { + Log.d(TAG, "Canceling upload because the duration has been edited for the first time..") cancelUpload(MediaBuilder.buildMedia(uri)) } it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index c536eab54d..0295d5cfd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -133,6 +133,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a binding.content.addAMessageInput.text = null dismiss() } + binding.content.viewOnceToggle.visible = state.selectedMedia.size == 1 && !state.isStory } initializeMentions() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 63083e2295..67249f0039 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -543,8 +543,10 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul return } val uri = mediaItem.uri - videoTimeLine.unregisterPlayerOnRangeChangeListener() - videoTimeLine.setInput(uri) + val updatedInputInTimeline = videoTimeLine.setInput(uri) + if (updatedInputInTimeline) { + videoTimeLine.unregisterDragListener() + } val size: Long = tryGetUriSize(requireContext(), uri, Long.MAX_VALUE) val maxSend = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()) if (size > maxSend) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java index 2a18a524ce..da566d096f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java @@ -52,6 +52,7 @@ public class MegaphoneRepository { public void onFirstEverAppLaunch() { executor.execute(() -> { database.markFinished(Event.ADD_A_PROFILE_PHOTO); + database.markFinished(Event.PNP_LAUNCH); resetDatabaseCache(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index f0f0d169b8..c87cb6fc18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Stream; import com.bumptech.glide.Glide; -import org.checkerframework.checker.units.qual.A; import org.signal.core.util.MapUtil; import org.signal.core.util.SetUtil; import org.signal.core.util.TranslationDetection; @@ -27,9 +26,9 @@ import org.thoughtcrime.securesms.database.model.MegaphoneRecord; import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice; import org.thoughtcrime.securesms.lock.SignalPinReminderDialog; import org.thoughtcrime.securesms.lock.SignalPinReminders; import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity; @@ -122,6 +121,7 @@ public final class Megaphones { put(Event.ENABLE_APP_UPDATES, shouldShowEnableAppUpdatesMegaphone(context) ? ALWAYS : NEVER); put(Event.DONATE_MOLLY, shouldShowDonateMegaphone(context, Event.DONATE_MOLLY, records) ? ShowForDurationSchedule.showForDays(7) : NEVER); put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER); + put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)): NEVER); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER); @@ -131,6 +131,18 @@ public final class Megaphones { }}; } + private static boolean shouldShowLinkedDeviceInactiveMegaphone() { + LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice(); + if (device == null) { + return false; + } + + long expiringAt = device.lastActiveTimestamp + FeatureFlags.linkedDeviceLifespan(); + long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0); + + return expiringIn < TimeUnit.DAYS.toMillis(7) && expiringIn > 0; + } + private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) { switch (record.getEvent()) { case PINS_FOR_ALL: @@ -151,6 +163,8 @@ public final class Megaphones { return buildDonateQ2Megaphone(context); case TURN_OFF_CENSORSHIP_CIRCUMVENTION: return buildTurnOffCircumventionMegaphone(context); + case LINKED_DEVICE_INACTIVE: + return buildLinkedDeviceInactiveMegaphone(context); case REMOTE_MEGAPHONE: return buildRemoteMegaphone(context); case BACKUP_SCHEDULE_PERMISSION: @@ -166,6 +180,29 @@ public final class Megaphones { } } + private static Megaphone buildLinkedDeviceInactiveMegaphone(Context context) { + LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice(); + if (device == null) { + throw new IllegalStateException("No linked device to show"); + } + + long expiringAt = device.lastActiveTimestamp + FeatureFlags.linkedDeviceLifespan(); + long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0); + int expiringDays = (int) TimeUnit.MILLISECONDS.toDays(expiringIn); + + return new Megaphone.Builder(Event.LINKED_DEVICE_INACTIVE, Megaphone.Style.BASIC) + .setTitle(R.string.LinkedDeviceInactiveMegaphone_title) + .setBody(context.getResources().getQuantityString(R.plurals.LinkedDeviceInactiveMegaphone_body, expiringDays, device.name, expiringDays)) + .setImage(R.drawable.ic_inactive_linked_device) + .setActionButton(R.string.LinkedDeviceInactiveMegaphone_got_it_button_label, (megaphone, listener) -> { + listener.onMegaphoneSnooze(Event.LINKED_DEVICE_INACTIVE); + }) + .setSecondaryButton(R.string.LinkedDeviceInactiveMegaphone_dont_remind_button_label, (megaphone, listener) -> { + listener.onMegaphoneCompleted(Event.LINKED_DEVICE_INACTIVE); + }) + .build(); + } + private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull MegaphoneRecord record) { if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) { return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN) @@ -458,13 +495,13 @@ public final class Megaphones { } private static boolean shouldShowAddAProfilePhotoMegaphone(@NonNull Context context) { - if (SignalStore.misc().hasEverHadAnAvatar()) { + if (SignalStore.misc().getHasEverHadAnAvatar()) { return false; } boolean hasAnAvatar = AvatarHelper.hasAvatar(context, Recipient.self().getId()); if (hasAnAvatar) { - SignalStore.misc().markHasEverHadAnAvatar(); + SignalStore.misc().setHasEverHadAnAvatar(true); return false; } @@ -534,6 +571,7 @@ public final class Megaphones { DONATE_MOLLY("donate_molly"), TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"), REMOTE_MEGAPHONE("remote_megaphone"), + LINKED_DEVICE_INACTIVE("linked_device_inactive"), BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"), SET_UP_YOUR_USERNAME("set_up_your_username"), PNP_LAUNCH("pnp_launch"), diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java index 75d2ca32ae..384e1b4e3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java @@ -138,7 +138,7 @@ public class OnboardingMegaphoneView extends FrameLayout { data.add(TYPE_INVITE); } - if (SignalStore.onboarding().shouldShowAddPhoto() && !SignalStore.misc().hasEverHadAnAvatar()) { + if (SignalStore.onboarding().shouldShowAddPhoto() && !SignalStore.misc().getHasEverHadAnAvatar()) { data.add(TYPE_ADD_PHOTO); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt new file mode 100644 index 0000000000..e60e1fd0f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messagedetails + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +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.core.os.bundleOf +import androidx.fragment.app.FragmentActivity +import org.thoughtcrime.securesms.compose.ComposeFullScreenDialogFragment +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsViewModel.AttachmentInfo +import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsViewModel.ViewState +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.viewModel + +class InternalMessageDetailsFragment : ComposeFullScreenDialogFragment() { + companion object { + const val ARG_MESSAGE_ID = "message_id" + + @JvmStatic + fun create(messageRecord: MessageRecord): InternalMessageDetailsFragment { + return InternalMessageDetailsFragment().apply { + arguments = bundleOf( + ARG_MESSAGE_ID to messageRecord.id + ) + } + } + } + + val viewModel: InternalMessageDetailsViewModel by viewModel { InternalMessageDetailsViewModel(requireArguments().getLong(ARG_MESSAGE_ID, 0)) } + + @Composable + override fun DialogContent() { + val state by viewModel.state + + state?.let { + Content(it) + } + } +} + +@Composable +private fun Content(state: ViewState) { + val context = LocalContext.current + + Surface( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + text = "Message Details", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + ClickToCopyRow( + name = "MessageId", + value = state.id.toString() + ) + ClickToCopyRow( + name = "Sent Timestamp", + value = state.sentTimestamp.toString() + ) + ClickToCopyRow( + name = "Received Timestamp", + value = state.receivedTimestamp.toString() + ) + + val serverTimestampString = if (state.serverSentTimestamp <= 0L) { + "N/A" + } else { + state.serverSentTimestamp.toString() + } + + ClickToCopyRow( + name = "Server Sent Timestamp", + value = serverTimestampString + ) + DetailRow( + name = "To", + value = state.to.toString(), + onClick = { + val fragmentManager = (context as FragmentActivity).supportFragmentManager + RecipientBottomSheetDialogFragment.show(fragmentManager, state.to, null) + } + ) + DetailRow( + name = "From", + value = state.from.toString(), + onClick = { + val fragmentManager = (context as FragmentActivity).supportFragmentManager + RecipientBottomSheetDialogFragment.show(fragmentManager, state.from, null) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Attachments", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + + if (state.attachments.isEmpty()) { + Text( + text = "None", + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + } else { + state.attachments.forEach { attachment -> + AttachmentBlock(attachment) + } + } + } + } +} + +@Composable +private fun DetailRow(name: String, value: String, onClick: () -> Unit) { + val formattedString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("$name: ") + } + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(value) + } + } + + Text( + text = formattedString, + modifier = Modifier + .clickable { onClick() } + .padding(8.dp) + .fillMaxWidth() + ) +} + +@Composable +private fun ClickToCopyRow(name: String, value: String, valueToCopy: String = value) { + val context: Context = LocalContext.current + + DetailRow( + name = name, + value = value, + onClick = { + Util.copyToClipboard(context, valueToCopy) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) +} + +@Composable +private fun AttachmentBlock(attachment: AttachmentInfo) { + ClickToCopyRow( + name = "ID", + value = attachment.id.toString() + ) + ClickToCopyRow( + name = "Filename", + value = attachment.fileName.toString() + ) + ClickToCopyRow( + name = "Content Type", + value = attachment.contentType + ) + ClickToCopyRow( + name = "Start Hash", + value = attachment.hashStart ?: "null" + ) + ClickToCopyRow( + name = "End Hash", + value = attachment.hashEnd ?: "null" + ) + ClickToCopyRow( + name = "Transform Properties", + value = attachment.transformProperties ?: "null" + ) +} + +@Preview +@Composable +private fun ContentPreview() { + Content( + ViewState( + id = 1, + sentTimestamp = 2, + receivedTimestamp = 3, + serverSentTimestamp = 4, + to = RecipientId.from(1), + from = RecipientId.from(2), + attachments = emptyList() + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt new file mode 100644 index 0000000000..b8d4d7004f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messagedetails + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.internal.util.JsonUtil + +class InternalMessageDetailsViewModel(val messageId: Long) : ViewModel() { + + private val _state: MutableState = mutableStateOf(null) + val state: State = _state + + init { + viewModelScope.launch(Dispatchers.IO) { + val messageRecord = SignalDatabase.messages.getMessageRecord(messageId) + val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId) + + _state.value = ViewState( + id = messageRecord.id, + sentTimestamp = messageRecord.dateSent, + receivedTimestamp = messageRecord.dateReceived, + serverSentTimestamp = messageRecord.serverTimestamp, + from = messageRecord.fromRecipient.id, + to = messageRecord.toRecipient.id, + attachments = attachments.map { attachment -> + val info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId) + + AttachmentInfo( + id = attachment.attachmentId.id, + contentType = attachment.contentType, + size = attachment.size, + fileName = attachment.fileName, + hashStart = info?.hashStart, + hashEnd = info?.hashEnd, + transformProperties = info?.transformProperties?.let { JsonUtil.toJson(it) } ?: "null" + ) + } + ) + } + } + + data class ViewState( + val id: Long, + val sentTimestamp: Long, + val receivedTimestamp: Long, + val serverSentTimestamp: Long, + val from: RecipientId, + val to: RecipientId, + val attachments: List + ) + + data class AttachmentInfo( + val id: Long, + val contentType: String, + val size: Long, + val fileName: String?, + val hashStart: String?, + val hashEnd: String?, + val transformProperties: String? + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java index 4f9c781f45..b00e1ffbec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -37,7 +37,7 @@ final class MessageDetailsAdapter extends ListAdapter callbacks.onInternalDetailsClicked(messageRecord)); } private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index feede98eaf..e9409ad507 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -14,6 +14,7 @@ import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.protocol.util.Pair import org.signal.ringrtc.CallException +import org.signal.ringrtc.CallId import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.GroupReceiptTable import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.IdentityTable +import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo import org.thoughtcrime.securesms.database.NoSuchMessageException import org.thoughtcrime.securesms.database.PaymentMetaDataUtil @@ -926,9 +928,9 @@ object SyncMessageProcessor { ) { log(envelopeTimestamp, "Synchronize read message. Count: ${readMessages.size}, Timestamps: ${readMessages.map { it.timestamp }}") - val threadToLatestRead: Map = HashMap() - val unhandled = SignalDatabase.messages.setTimestampReadFromSyncMessageProto(readMessages, envelopeTimestamp, threadToLatestRead.toMutableMap()) - val markedMessages: List = SignalDatabase.threads.setReadSince(threadToLatestRead, false) + val threadToLatestRead: MutableMap = HashMap() + val unhandled: Collection = SignalDatabase.messages.setTimestampReadFromSyncMessage(readMessages, envelopeTimestamp, threadToLatestRead) + val markedMessages: List = SignalDatabase.threads.setReadSince(threadToLatestRead, false) if (Util.hasItems(markedMessages)) { log("Updating past SignalDatabase.messages: " + markedMessages.size) @@ -1244,22 +1246,44 @@ object SyncMessageProcessor { } private fun handleSynchronizeCallLogEvent(callLogEvent: CallLogEvent, envelopeTimestamp: Long) { - if (callLogEvent.timestamp == null) { - log(envelopeTimestamp, "Synchronize call log event has null timestamp") - return + val timestamp = callLogEvent.timestamp + val callId = callLogEvent.callId?.let { CallId(it) } + val peer: RecipientId? = callLogEvent.conversationId?.let { byteString -> + ACI.parseOrNull(byteString)?.let { RecipientId.from(it) } + ?: GroupId.pushOrNull(byteString.toByteArray())?.let { SignalDatabase.recipients.getByGroupId(it).orNull() } + ?: CallLinkRoomId.fromBytes(byteString.toByteArray()).let { SignalDatabase.recipients.getByCallLinkRoomId(it).orNull() } } - when (callLogEvent.type) { + if (callId != null && peer != null) { + val call = SignalDatabase.calls.getCallById(callId.longValue(), peer) + + if (call != null) { + log(envelopeTimestamp, "Synchronizing call log event with exact call data.") + synchronizeCallLogEventViaTimestamp(envelopeTimestamp, callLogEvent.type, call.timestamp) + return + } + } + + if (timestamp != null) { + warn(envelopeTimestamp, "Synchronize call log event using timestamp instead of exact values") + synchronizeCallLogEventViaTimestamp(envelopeTimestamp, callLogEvent.type, timestamp) + } else { + log(envelopeTimestamp, "Failed to synchronize call log event, not enough information.") + } + } + + private fun synchronizeCallLogEventViaTimestamp(envelopeTimestamp: Long, eventType: CallLogEvent.Type?, timestamp: Long) { + when (eventType) { CallLogEvent.Type.CLEAR -> { - SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(callLogEvent.timestamp!!) - SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(callLogEvent.timestamp!!) + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(timestamp) + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(timestamp) } CallLogEvent.Type.MARKED_AS_READ -> { - SignalDatabase.calls.markAllCallEventsRead(callLogEvent.timestamp!!) + SignalDatabase.calls.markAllCallEventsRead(timestamp) } - else -> log(envelopeTimestamp, "Synchronize call log event has an invalid type ${callLogEvent.type}, ignoring.") + else -> log(envelopeTimestamp, "Synchronize call log event has an invalid type $eventType, ignoring.") } } @@ -1481,7 +1505,7 @@ object SyncMessageProcessor { true ) - SignalStore.misc().setPniInitializedDevices(true) + SignalStore.misc().hasPniInitializedDevices = true ApplicationDependencies.getGroupsV2Authorization().clear() } catch (e: InvalidMessageException) { warn(envelopeTimestamp, "Invalid signed prekey received while synchronize number change", e) diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index d96f5b5f5c..b2b9dd4a05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -134,11 +134,12 @@ public class ApplicationMigrations { static final int STORAGE_LOCAL_UNKNOWNS_FIX = 101; static final int PNP_LAUNCH = 102; static final int EMOJI_VERSION_10 = 103; + static final int ATTACHMENT_HASH_BACKFILL = 104; } - public static final int CURRENT_VERSION = 103; + public static final int CURRENT_VERSION = 104; - /** + /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call * to {@link JobManager#beginJobLoop()}. Otherwise, other non-migration jobs may have started * executing before we add the migration jobs. @@ -155,7 +156,7 @@ public class ApplicationMigrations { return; } else { Log.d(TAG, "About to update. Clearing deprecation flag."); - SignalStore.misc().clearClientDeprecated(); + SignalStore.misc().setClientDeprecated(false); } final int lastSeenVersion = TextSecurePreferences.getAppMigrationVersion(context); @@ -603,6 +604,10 @@ public class ApplicationMigrations { jobs.put(Version.EMOJI_VERSION_10, new EmojiDownloadMigrationJob()); } + if (lastSeenVersion < Version.ATTACHMENT_HASH_BACKFILL) { + jobs.put(Version.ATTACHMENT_HASH_BACKFILL, new AttachmentHashBackfillMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AttachmentHashBackfillMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/AttachmentHashBackfillMigrationJob.kt new file mode 100644 index 0000000000..3b8718a7c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AttachmentHashBackfillMigrationJob.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.migrations + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.AttachmentHashBackfillJob +import java.lang.Exception + +/** + * Kicks off the attachment hash backfill process by enqueueing a [AttachmentHashBackfillJob]. + */ +internal class AttachmentHashBackfillMigrationJob(parameters: Parameters = Parameters.Builder().build()) : MigrationJob(parameters) { + + companion object { + val TAG = Log.tag(AttachmentHashBackfillMigrationJob::class.java) + const val KEY = "AttachmentHashBackfillMigrationJob" + } + + override fun getFactoryKey(): String = KEY + + override fun isUiBlocking(): Boolean = false + + override fun performMigration() { + ApplicationDependencies.getJobManager().add(AttachmentHashBackfillJob()) + } + + override fun shouldRetry(e: Exception): Boolean = false + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): AttachmentHashBackfillMigrationJob { + return AttachmentHashBackfillMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java index 80a7e9e7dc..eb44e169e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.util.MediaUtil; import java.io.IOException; +import java.util.Optional; /** * SlideFactory encapsulates logic related to constructing slides from a set of paramaeters as defined @@ -171,7 +172,7 @@ public final class SlideFactory { case IMAGE: return new ImageSlide(context, uri, mimeType, dataSize, width, height, false, null, blurHash, transformProperties); case GIF: return new GifSlide(context, uri, dataSize, width, height); case AUDIO: return new AudioSlide(context, uri, dataSize, false); - case VIDEO: return new VideoSlide(context, uri, dataSize, gif); + case VIDEO: return new VideoSlide(context, uri, dataSize, gif, null, AttachmentTable.TransformProperties.forSentMediaQuality(transformProperties != null ? transformProperties.sentMediaQuality : SentMediaQuality.STANDARD.getCode())); case VCARD: case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); default: throw new AssertionError("unrecognized enum"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java index e88570fef9..cc4b006efe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/RemoteDeprecationDetectorInterceptor.java @@ -23,7 +23,7 @@ public final class RemoteDeprecationDetectorInterceptor implements Interceptor { if (response.code() == 499 && !SignalStore.misc().isClientDeprecated()) { Log.w(TAG, "Received 499. Client version is deprecated."); - SignalStore.misc().markClientDeprecated(); + SignalStore.misc().setClientDeprecated(true); } return response; diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt new file mode 100644 index 0000000000..13b9ecd335 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt @@ -0,0 +1,490 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.nicknames + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Previews +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.TextFields +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.getParcelableCompat +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.viewModel + +/** + * Fragment allowing a user to set a custom nickname for the given recipient. + */ +class NicknameActivity : PassphraseRequiredActivity(), NicknameContentCallback { + + private val theme = DynamicNoActionBarTheme() + + private val args: Args by lazy { + Args.fromBundle(intent.extras!!) + } + + private val viewModel: NicknameViewModel by viewModel { + NicknameViewModel(args.recipientId) + } + + override fun onPreCreate() { + theme.onCreate(this) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + setContent { + val state by viewModel.state + + LaunchedEffect(state.formState) { + if (state.formState == NicknameState.FormState.SAVED) { + supportFinishAfterTransition() + } + } + + SignalTheme { + NicknameContent( + callback = remember { this }, + state = state, + focusNoteFirst = args.focusNoteFirst + ) + } + } + } + + override fun onResume() { + super.onResume() + theme.onResume(this) + } + + override fun onNavigationClick() { + supportFinishAfterTransition() + } + + override fun onSaveClick() { + viewModel.save() + } + + override fun onDeleteClick() { + viewModel.delete() + } + + override fun onFirstNameChanged(value: String) { + viewModel.onFirstNameChanged(value) + } + + override fun onLastNameChanged(value: String) { + viewModel.onLastNameChanged(value) + } + + override fun onNoteChanged(value: String) { + viewModel.onNoteChanged(value) + } + + /** + * @param recipientId The recipient to edit the nickname and note for + * @param focusNoteFirst Whether default focus should be on the edit note field + */ + data class Args( + val recipientId: RecipientId, + val focusNoteFirst: Boolean + ) { + fun toBundle(): Bundle { + return bundleOf( + RECIPIENT_ID to recipientId, + FOCUS_NOTE_FIRST to focusNoteFirst + ) + } + + companion object { + private const val RECIPIENT_ID = "recipient_id" + private const val FOCUS_NOTE_FIRST = "focus_note_first" + + fun fromBundle(bundle: Bundle): Args { + return Args( + recipientId = bundle.getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!, + focusNoteFirst = bundle.getBoolean(FOCUS_NOTE_FIRST) + ) + } + } + } + + /** + * Launches the nickname activity with the proper arguments. + * Doesn't return a response, but is a helpful signal to know when to refresh UI. + */ + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Args): Intent { + return Intent(context, NicknameActivity::class.java).putExtras(input.toBundle()) + } + + override fun parseResult(resultCode: Int, intent: Intent?) = Unit + } +} + +private interface NicknameContentCallback { + fun onNavigationClick() + fun onSaveClick() + fun onDeleteClick() + fun onFirstNameChanged(value: String) + fun onLastNameChanged(value: String) + fun onNoteChanged(value: String) +} + +@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NicknameContentPreview() { + Previews.Preview { + val callback = remember { + object : NicknameContentCallback { + override fun onNavigationClick() = Unit + override fun onSaveClick() = Unit + override fun onDeleteClick() = Unit + override fun onFirstNameChanged(value: String) = Unit + override fun onLastNameChanged(value: String) = Unit + override fun onNoteChanged(value: String) = Unit + } + } + + NicknameContent( + callback = callback, + state = NicknameState( + isEditing = true, + note = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temor incididunt ut labore et dolore magna aliqua. Ut enim ad minimu" + ), + focusNoteFirst = false + ) + } +} + +@Composable +private fun NicknameContent( + callback: NicknameContentCallback, + state: NicknameState, + focusNoteFirst: Boolean +) { + var displayDeletionDialog by remember { mutableStateOf(false) } + + Scaffolds.Settings( + title = stringResource(id = R.string.NicknameActivity__nickname), + onNavigationClick = callback::onNavigationClick, + navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24) + ) { paddingValues -> + + val firstNameFocusRequester = remember { FocusRequester() } + val noteFocusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier + .padding(paddingValues) + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + ) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Text( + text = stringResource(id = R.string.NicknameActivity__nicknames_amp_notes), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } + + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) { + if (state.recipient != null) { + AvatarImage(recipient = state.recipient, modifier = Modifier.size(80.dp)) + } else { + Spacer(modifier = Modifier.size(80.dp)) + } + } + } + + item { + ClearableTextField( + value = state.firstName, + hint = stringResource(id = R.string.NicknameActivity__first_name), + clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_first_name), + enabled = true, + singleLine = true, + onValueChange = callback::onFirstNameChanged, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .focusRequester(firstNameFocusRequester) + .fillMaxWidth() + .padding(bottom = 20.dp) + ) + } + + item { + ClearableTextField( + value = state.lastName, + hint = stringResource(id = R.string.NicknameActivity__last_name), + clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_last_name), + enabled = true, + singleLine = true, + onValueChange = callback::onLastNameChanged, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp) + ) + } + + item { + ClearableTextField( + value = state.note, + hint = stringResource(id = R.string.NicknameActivity__note), + clearContentDescription = "", + clearable = false, + enabled = true, + onValueChange = callback::onNoteChanged, + keyboardActions = KeyboardActions.Default, + keyboardOptions = KeyboardOptions.Default, + charactersRemaining = state.noteCharactersRemaining, + modifier = Modifier + .focusRequester(noteFocusRequester) + .fillMaxWidth() + .padding(bottom = 20.dp) + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp) + ) { + if (state.isEditing) { + TextButton( + onClick = { + displayDeletionDialog = true + }, + modifier = Modifier + .align(Alignment.BottomStart) + ) { + Text( + text = stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.primary + ) + } + } + Buttons.LargeTonal( + onClick = callback::onSaveClick, + enabled = state.canSave, + modifier = Modifier + .align(Alignment.BottomEnd) + ) { + Text( + text = stringResource(id = R.string.NicknameActivity__save) + ) + } + } + } + + if (displayDeletionDialog) { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.NicknameActivity__delete_nickname), + body = stringResource(id = R.string.NicknameActivity__this_will_permanently_delete_this_nickname_and_note), + confirm = stringResource(id = R.string.delete), + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = { + callback.onDeleteClick() + }, + onDismiss = { displayDeletionDialog = false } + ) + } + + LaunchedEffect(state.hasBecomeReady) { + if (state.hasBecomeReady) { + if (focusNoteFirst) { + noteFocusRequester.requestFocus() + } else { + firstNameFocusRequester.requestFocus() + } + } + } + } +} + +@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ClearableTextFieldPreview() { + Previews.Preview { + val focusRequester = remember { FocusRequester() } + + Column(modifier = Modifier.padding(16.dp)) { + ClearableTextField( + value = "", + hint = "Without content", + enabled = true, + onValueChange = {}, + clearContentDescription = "" + ) + Spacer(modifier = Modifier.size(16.dp)) + ClearableTextField( + value = "Test", + hint = "With Content", + enabled = true, + onValueChange = {}, + clearContentDescription = "" + ) + Spacer(modifier = Modifier.size(16.dp)) + ClearableTextField( + value = "", + hint = "Disabled", + enabled = false, + onValueChange = {}, + clearContentDescription = "" + ) + Spacer(modifier = Modifier.size(16.dp)) + ClearableTextField( + value = "", + hint = "Focused", + enabled = true, + onValueChange = {}, + modifier = Modifier.focusRequester(focusRequester), + clearContentDescription = "" + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ClearableTextField( + value: String, + hint: String, + clearContentDescription: String, + enabled: Boolean, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = false, + clearable: Boolean = true, + charactersRemaining: Int = Int.MAX_VALUE, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default +) { + var focused by remember { mutableStateOf(false) } + + val displayCountdown = charactersRemaining <= 100 + + val clearButton: @Composable () -> Unit = { + ClearButton( + visible = focused, + onClick = { onValueChange("") }, + contentDescription = clearContentDescription + ) + } + + Box(modifier = modifier) { + TextFields.TextField( + value = value, + onValueChange = onValueChange, + label = { + Text(text = hint) + }, + enabled = enabled, + singleLine = singleLine, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.hasFocus && clearable }, + colors = TextFieldDefaults.colors( + unfocusedLabelColor = MaterialTheme.colorScheme.outline, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline + ), + trailingIcon = if (clearable) clearButton else null, + contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp) + ) + + AnimatedVisibility( + visible = displayCountdown, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 10.dp, end = 12.dp) + ) { + Text( + text = "$charactersRemaining", + style = MaterialTheme.typography.bodySmall, + color = if (charactersRemaining <= 5) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline + ) + } + } +} + +@Composable +private fun ClearButton( + visible: Boolean, + onClick: () -> Unit, + contentDescription: String +) { + AnimatedVisibility(visible = visible) { + IconButton( + onClick = onClick + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_x_circle_fill_24), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.outline + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt new file mode 100644 index 0000000000..e8caee4ee9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameState.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.nicknames + +import org.thoughtcrime.securesms.recipients.Recipient + +data class NicknameState( + val recipient: Recipient? = null, + val firstName: String = "", + val lastName: String = "", + val note: String = "", + val noteCharactersRemaining: Int = 0, + val formState: FormState = FormState.LOADING, + val hasBecomeReady: Boolean = false, + val isEditing: Boolean = false +) { + + private val isFormBlank: Boolean = firstName.isBlank() && lastName.isBlank() && note.isBlank() + private val hasNameOrNote: Boolean = firstName.isNotBlank() || lastName.isNotBlank() || note.isNotBlank() + private val isFormReady: Boolean = formState == FormState.READY + private val isBlankFormDuringEdit: Boolean = isFormBlank && isEditing + + val canSave: Boolean = isFormReady && (hasNameOrNote || isBlankFormDuringEdit) + enum class FormState { + LOADING, + READY, + SAVING, + SAVED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt new file mode 100644 index 0000000000..06f97f8e84 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameViewModel.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.nicknames + +import androidx.annotation.MainThread +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.BreakIteratorCompat +import org.signal.core.util.isNotNullOrBlank +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class NicknameViewModel( + private val recipientId: RecipientId +) : ViewModel() { + companion object { + private const val NAME_MAX_LENGTH = 26 + private const val NOTE_MAX_LENGTH = 240 + } + + private val internalState = mutableStateOf(NicknameState()) + private val iteratorCompat = BreakIteratorCompat.getInstance() + + val state: MutableState = internalState + + private val recipientDisposable = Recipient.observable(recipientId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { recipient -> + internalState.value = if (state.value.formState == NicknameState.FormState.LOADING) { + val noteLength = iteratorCompat.run { + setText(recipient.note ?: "") + countBreaks() + } + + NicknameState( + recipient = recipient, + firstName = recipient.nickname.givenName, + lastName = recipient.nickname.familyName, + note = recipient.note ?: "", + noteCharactersRemaining = NOTE_MAX_LENGTH - noteLength, + formState = NicknameState.FormState.READY, + hasBecomeReady = true, + isEditing = !recipient.nickname.isEmpty || recipient.note?.isNotNullOrBlank() == true + ) + } else { + state.value.copy(recipient = recipient) + } + } + + override fun onCleared() { + recipientDisposable.dispose() + } + + @MainThread + fun onFirstNameChanged(value: String) { + iteratorCompat.setText(value) + internalState.value = state.value.copy(firstName = iteratorCompat.take(NAME_MAX_LENGTH).toString()) + } + + @MainThread + fun onLastNameChanged(value: String) { + iteratorCompat.setText(value) + internalState.value = state.value.copy(lastName = iteratorCompat.take(NAME_MAX_LENGTH).toString()) + } + + @MainThread + fun onNoteChanged(value: String) { + if (internalState.value.noteCharactersRemaining == 0 && value.graphemeCount > NOTE_MAX_LENGTH) { + return + } + + iteratorCompat.setText(value) + val trimmed = iteratorCompat.take(NOTE_MAX_LENGTH) + val count = trimmed.graphemeCount + + internalState.value = state.value.copy( + note = trimmed.toString(), + noteCharactersRemaining = NOTE_MAX_LENGTH - count + ) + } + + @MainThread + fun delete() { + viewModelScope.launch { + internalState.value = state.value.copy(formState = NicknameState.FormState.SAVING) + + withContext(Dispatchers.IO) { + SignalDatabase.recipients.setNicknameAndNote( + recipientId, + ProfileName.EMPTY, + "" + ) + } + + internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED) + } + } + + @MainThread + fun save() { + viewModelScope.launch { + val stateSnapshot = state.value.copy(formState = NicknameState.FormState.SAVING) + internalState.value = stateSnapshot + + withContext(Dispatchers.IO) { + SignalDatabase.recipients.setNicknameAndNote( + recipientId, + ProfileName.fromParts(stateSnapshot.firstName, stateSnapshot.lastName), + stateSnapshot.note + ) + } + + internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED) + } + } + + private val CharSequence.graphemeCount: Int + get() { + iteratorCompat.setText(this) + return iteratorCompat.countBreaks() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt new file mode 100644 index 0000000000..11ddfbff7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheet.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.nicknames + +import android.os.Bundle +import android.text.util.Linkify +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +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.compose.ui.viewinterop.AndroidView +import androidx.core.os.bundleOf +import androidx.core.text.method.LinkMovementMethodCompat +import androidx.core.text.util.LinkifyCompat +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Previews +import org.signal.core.util.getParcelableCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.viewModel + +/** + * Allows user to view the full note for a given recipient. + */ +class ViewNoteSheet : ComposeBottomSheetDialogFragment() { + + companion object { + + private const val RECIPIENT_ID = "recipient_id" + + @JvmStatic + fun create(recipientId: RecipientId): ViewNoteSheet { + return ViewNoteSheet().apply { + arguments = bundleOf( + RECIPIENT_ID to recipientId + ) + } + } + } + + private val recipientId: RecipientId by lazy { + requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!! + } + + private val viewModel: ViewNoteSheetViewModel by viewModel { + ViewNoteSheetViewModel(recipientId) + } + + private lateinit var editNoteLauncher: ActivityResultLauncher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + editNoteLauncher = registerForActivityResult(NicknameActivity.Contract()) {} + } + + @Composable + override fun SheetContent() { + val note by remember { viewModel.note } + + ViewNoteBottomSheetContent( + onEditNoteClick = this::onEditNoteClick, + note = note + ) + } + + private fun onEditNoteClick() { + editNoteLauncher.launch( + NicknameActivity.Args( + recipientId = recipientId, + focusNoteFirst = true + ) + ) + + dismissAllowingStateLoss() + } +} + +@Preview +@Composable +private fun ViewNoteBottomSheetContentPreview() { + Previews.Preview { + ViewNoteBottomSheetContent( + onEditNoteClick = {}, + note = "Lorem ipsum dolor sit amet\n\nWebsite: https://example.com" + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ViewNoteBottomSheetContent( + onEditNoteClick: () -> Unit, + note: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + ) { + BottomSheets.Handle() + + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(id = R.string.ViewNoteSheet__note) + ) + }, + actions = { + IconButton(onClick = onEditNoteClick) { + Icon( + painter = painterResource(id = R.drawable.symbol_edit_24), + contentDescription = stringResource(id = R.string.ViewNoteSheet__edit_note) + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ) + ) + + val mask = if (LocalInspectionMode.current) { + Linkify.WEB_URLS + } else { + Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS + } + + AndroidView( + factory = { context -> + val view = EmojiTextView(context) + + @Suppress("DEPRECATION") + view.setTextAppearance(context, R.style.Signal_Text_BodyLarge) + view.movementMethod = LinkMovementMethodCompat.getInstance() + + view + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp) + ) { + it.text = note + + LinkifyCompat.addLinks(it, mask) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt new file mode 100644 index 0000000000..517d6f96d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/ViewNoteSheetViewModel.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.nicknames + +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.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class ViewNoteSheetViewModel( + recipientId: RecipientId +) : ViewModel() { + private val internalNote = mutableStateOf("") + val note: State = internalNote + + private val recipientDisposable = Recipient.observable(recipientId) + .map { it.note ?: "" } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { internalNote.value = it } + + override fun onCleared() { + recipientDisposable.dispose() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java index d711cbe1f7..5486fced5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java @@ -23,9 +23,11 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsTestRestoreActivity; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.ProfileUploadJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -236,7 +238,9 @@ public class PinRestoreEntryFragment extends LoggingFragment { Activity activity = requireActivity(); - if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) { + if (BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED) { + startActivity(MessageBackupsTestRestoreActivity.Companion.getIntent(activity)); + } else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) { final Intent main = MainActivity.clearTop(activity); final Intent profile = CreateProfileActivity.getIntentForUserProfile(activity); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java index 6a135fa45b..5716ab7c4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java @@ -148,7 +148,7 @@ public class EditSelfProfileRepository implements EditProfileRepository { RegistrationUtil.maybeMarkRegistrationComplete(); if (avatar != null) { - SignalStore.misc().markHasEverHadAnAvatar(); + SignalStore.misc().setHasEverHadAnAvatar(true); } return UploadResult.SUCCESS; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileFragment.kt index 1a20755bfc..07010795d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileFragment.kt @@ -36,7 +36,9 @@ import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.profiles.manage.EditProfileViewModel.AvatarState import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameDeleteResult import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity import org.thoughtcrime.securesms.util.NameUtil.getAbbreviation +import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.livedata.LiveDataUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.views.SimpleProgressDialog @@ -54,6 +56,8 @@ class EditProfileFragment : LoggingFragment() { private lateinit var binding: EditProfileFragmentBinding private lateinit var disposables: LifecycleDisposable + private val DISABLED_ALPHA = 0.4f + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = EditProfileFragmentBinding.inflate(inflater, container, false) return binding.root @@ -74,11 +78,27 @@ class EditProfileFragment : LoggingFragment() { initializeViewModel() binding.toolbar.setNavigationOnClickListener { requireActivity().finish() } - binding.manageProfileEditPhoto.setOnClickListener { onEditAvatarClicked() } - binding.manageProfileNameContainer.setOnClickListener { v: View -> findNavController(v).safeNavigate(EditProfileFragmentDirections.actionManageProfileName()) } + + binding.manageProfileEditPhoto.setOnClickListener { + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else { + onEditAvatarClicked() + } + } + + binding.manageProfileNameContainer.setOnClickListener { v: View -> + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else { + findNavController(v).safeNavigate(EditProfileFragmentDirections.actionManageProfileName()) + } + } binding.manageProfileUsernameContainer.setOnClickListener { v: View -> - if (SignalStore.account().username != null) { + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else if (SignalStore.account().username != null) { MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_Signal_MaterialAlertDialog_List) .setItems(R.array.username_edit_entries) { _: DialogInterface?, w: Int -> when (w) { @@ -93,10 +113,18 @@ class EditProfileFragment : LoggingFragment() { } } - binding.manageProfileAboutContainer.setOnClickListener { v: View -> findNavController(v).safeNavigate(EditProfileFragmentDirections.actionManageAbout()) } + binding.manageProfileAboutContainer.setOnClickListener { v: View -> + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else { + findNavController(v).safeNavigate(EditProfileFragmentDirections.actionManageAbout()) + } + } parentFragmentManager.setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, viewLifecycleOwner) { _: String?, bundle: Bundle -> - if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) { + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) { viewModel.onAvatarSelected(requireContext(), null) } else { val result = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA) @@ -114,10 +142,14 @@ class EditProfileFragment : LoggingFragment() { binding.manageProfileBadgesContainer.visibility = View.GONE binding.manageProfileAvatar.setOnClickListener { - startActivity( - AvatarPreviewActivity.intentFromRecipientId(requireContext(), Recipient.self().id), - AvatarPreviewActivity.createTransitionBundle(requireActivity(), binding.manageProfileAvatar) - ) + if (!viewModel.isRegisteredAndUpToDate) { + onClickWhenUnregisteredOrDeprecated() + } else { + startActivity( + AvatarPreviewActivity.intentFromRecipientId(requireContext(), Recipient.self().id), + AvatarPreviewActivity.createTransitionBundle(requireActivity(), binding.manageProfileAvatar) + ) + } } } @@ -147,6 +179,10 @@ class EditProfileFragment : LoggingFragment() { } else { Glide.with(this).load(null as Drawable?).into(binding.manageProfileAvatar) } + + binding.manageProfileAvatar.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA + binding.manageProfileAvatarInitials.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA + binding.manageProfileEditPhoto.isEnabled = viewModel.isRegisteredAndUpToDate } private fun presentAvatarPlaceholder(avatarState: AvatarState) { @@ -198,6 +234,9 @@ class EditProfileFragment : LoggingFragment() { } else { binding.manageProfileName.text = profileName.toString() } + + binding.manageProfileName.isEnabled = viewModel.isRegisteredAndUpToDate + binding.manageProfileNameIcon.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA } private fun presentUsername(username: String?) { @@ -237,6 +276,9 @@ class EditProfileFragment : LoggingFragment() { binding.usernameLinkContainer.visibility = View.GONE binding.usernameInfoText.setText(R.string.ManageProfileFragment__username_footer_no_username) } + + binding.manageProfileUsername.isEnabled = viewModel.isRegisteredAndUpToDate + binding.manageProfileUsernameIcon.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA } private fun presentAbout(about: String?) { @@ -245,6 +287,9 @@ class EditProfileFragment : LoggingFragment() { } else { binding.manageProfileAbout.text = about } + + binding.manageProfileAbout.isEnabled = viewModel.isRegisteredAndUpToDate + binding.manageProfileAboutIcon.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA } private fun presentAboutEmoji(aboutEmoji: String?) { @@ -266,6 +311,14 @@ class EditProfileFragment : LoggingFragment() { } else { binding.manageProfileBadge.setBadge(null) } + + binding.manageProfileBadges.isEnabled = viewModel.isRegisteredAndUpToDate + binding.manageProfileBadge.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA + binding.manageProfileBadgesIcon.alpha = if (viewModel.isRegisteredAndUpToDate) 1.0f else DISABLED_ALPHA + + if (!viewModel.isRegisteredAndUpToDate) { + binding.manageProfileBadge.setOnClickListener { onClickWhenUnregisteredOrDeprecated() } + } } private fun presentEvent(event: EditProfileViewModel.Event) { @@ -309,4 +362,28 @@ class EditProfileFragment : LoggingFragment() { UsernameDeleteResult.NETWORK_ERROR -> Snackbar.make(requireView(), R.string.ManageProfileFragment__couldnt_delete_username, Snackbar.LENGTH_SHORT).show() } } + + private fun onClickWhenUnregisteredOrDeprecated() { + if (viewModel.isDeprecated) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.EditProfileFragment_deprecated_dialog_title) + .setMessage(R.string.EditProfileFragment_deprecated_dialog_body) + .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + .setPositiveButton(R.string.EditProfileFragment_deprecated_dialog_update_button) { d, _ -> + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()) + d.dismiss() + } + .show() + } else { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.EditProfileFragment_unregistered_dialog_title) + .setMessage(R.string.EditProfileFragment_unregistered_dialog_body) + .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + .setPositiveButton(R.string.EditProfileFragment_unregistered_dialog_reregister_button) { d, _ -> + startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext())) + d.dismiss() + } + .show() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileRepository.java index e004166b35..da0a95a6de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileRepository.java @@ -59,7 +59,7 @@ final class EditProfileRepository { try { ProfileUtil.uploadProfileWithAvatar(new StreamDetails(new ByteArrayInputStream(data), contentType, data.length)); AvatarHelper.setAvatar(context, Recipient.self().getId(), new ByteArrayInputStream(data)); - SignalStore.misc().markHasEverHadAnAvatar(); + SignalStore.misc().setHasEverHadAnAvatar(true); ApplicationDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob()); callback.accept(Result.SUCCESS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileViewModel.java index ee2b4705f9..c15ca12523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileViewModel.java @@ -24,8 +24,8 @@ import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.util.DefaultValueLiveData; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.whispersystems.signalservice.api.util.StreamDetails; @@ -107,6 +107,14 @@ class EditProfileViewModel extends ViewModel { return UsernameRepository.deleteUsernameAndLink().observeOn(AndroidSchedulers.mainThread()); } + public boolean isRegisteredAndUpToDate() { + return !TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()) && SignalStore.account().isRegistered() && !SignalStore.misc().isClientDeprecated(); + } + + public boolean isDeprecated() { + return SignalStore.misc().isClientDeprecated(); + } + public void onAvatarSelected(@NonNull Context context, @Nullable Media media) { previousAvatar = internalAvatarState.getValue() != null ? internalAvatarState.getValue().getAvatar() : null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index 660e2221b4..b12d7fc22d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -130,7 +130,7 @@ object UsernameRepository { @WorkerThread @JvmStatic fun reclaimUsernameIfNecessary(): UsernameReclaimResult { - if (!SignalStore.misc().needsUsernameRestore()) { + if (!SignalStore.misc().needsUsernameRestore) { Log.d(TAG, "[reclaimUsernameIfNecessary] No need to restore username. Skipping.") return UsernameReclaimResult.SUCCESS } @@ -140,7 +140,7 @@ object UsernameRepository { if (username == null || link == null) { Log.d(TAG, "[reclaimUsernameIfNecessary] No username or link to restore. Skipping.") - SignalStore.misc().setNeedsUsernameRestore(false) + SignalStore.misc().needsUsernameRestore = false return UsernameReclaimResult.SUCCESS } @@ -149,13 +149,13 @@ object UsernameRepository { when (result) { UsernameReclaimResult.SUCCESS -> { Log.i(TAG, "[reclaimUsernameIfNecessary] Successfully reclaimed username and link.") - SignalStore.misc().setNeedsUsernameRestore(false) + SignalStore.misc().needsUsernameRestore = false } UsernameReclaimResult.PERMANENT_ERROR -> { Log.w(TAG, "[reclaimUsernameIfNecessary] Permanently failed to reclaim username and link. User will see an error.") SignalStore.account().usernameSyncState = AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED - SignalStore.misc().setNeedsUsernameRestore(false) + SignalStore.misc().needsUsernameRestore = false } UsernameReclaimResult.NETWORK_ERROR -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 7b0b08899a..b6ded64af9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -140,6 +140,8 @@ public class Recipient { private final CallLinkRoomId callLinkRoomId; private final Optional groupRecord; private final PhoneNumberSharingState phoneNumberSharing; + private final ProfileName nickname; + private final String note; /** * Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be @@ -430,6 +432,8 @@ public class Recipient { this.callLinkRoomId = null; this.groupRecord = Optional.empty(); this.phoneNumberSharing = PhoneNumberSharingState.UNKNOWN; + this.nickname = ProfileName.EMPTY; + this.note = null; } public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { @@ -486,6 +490,8 @@ public class Recipient { this.callLinkRoomId = details.callLinkRoomId; this.groupRecord = details.groupRecord; this.phoneNumberSharing = details.phoneNumberSharing; + this.nickname = details.nickname; + this.note = details.note; } public @NonNull RecipientId getId() { @@ -500,6 +506,10 @@ public class Recipient { return contactUri; } + public @Nullable String getNote() { + return note; + } + public @Nullable String getGroupName(@NonNull Context context) { if (groupId != null && Util.isEmpty(this.groupName)) { RecipientId selfId = ApplicationDependencies.getRecipientCache().getSelfId(); @@ -550,6 +560,7 @@ public class Recipient { */ public boolean hasAUserSetDisplayName(@NonNull Context context) { return !TextUtils.isEmpty(getGroupName(context)) || + !TextUtils.isEmpty(getNickname().toString()) || !TextUtils.isEmpty(systemContactName) || !TextUtils.isEmpty(getProfileName().toString()); } @@ -578,6 +589,10 @@ public class Recipient { private @Nullable String getNameFromLocalData(@NonNull Context context) { String name = getGroupName(context); + if (Util.isEmpty(name)) { + name = getNickname().toString(); + } + if (Util.isEmpty(name)) { name = systemContactName; } @@ -601,6 +616,11 @@ public class Recipient { String name = isSelf ? getProfileName().toString() : getGroupName(context); name = StringUtil.isolateBidi(name); + if (Util.isEmpty(name)) { + name = isSelf ? getGroupName(context) : getNickname().toString(); + name = StringUtil.isolateBidi(name); + } + if (Util.isEmpty(name)) { name = isSelf ? getGroupName(context) : systemContactName; name = StringUtil.isolateBidi(name); @@ -628,8 +648,12 @@ public class Recipient { public @NonNull String getShortDisplayName(@NonNull Context context) { String name = Util.getFirstNonEmpty(getGroupName(context), + getNickname().getGivenName(), + getNickname().toString(), getSystemProfileName().getGivenName(), + getSystemProfileName().toString(), getProfileName().getGivenName(), + getProfileName().toString(), getUsername().orElse(null), getDisplayName(context)); @@ -826,6 +850,10 @@ public class Recipient { return requireSmsAddress(); } + public @NonNull ProfileName getNickname() { + return nickname; + } + public @NonNull ProfileName getProfileName() { return signalProfileName; } @@ -961,6 +989,7 @@ public class Recipient { else if (isGroupInternal()) return fallbackPhotoProvider.getPhotoForGroup(); else if (isGroup()) return fallbackPhotoProvider.getPhotoForGroup(); else if (!TextUtils.isEmpty(groupName)) return fallbackPhotoProvider.getPhotoForRecipientWithName(groupName, targetSize); + else if (!nickname.isEmpty()) return fallbackPhotoProvider.getPhotoForRecipientWithName(nickname.toString(), targetSize); else if (!TextUtils.isEmpty(systemContactName)) return fallbackPhotoProvider.getPhotoForRecipientWithName(systemContactName, targetSize); else if (!signalProfileName.isEmpty()) return fallbackPhotoProvider.getPhotoForRecipientWithName(signalProfileName.toString(), targetSize); else return fallbackPhotoProvider.getPhotoForRecipientWithoutName(); @@ -1226,6 +1255,18 @@ public class Recipient { return Objects.requireNonNull(callLinkRoomId); } + public @NonNull byte[] requireCallConversationId() { + if (isPushGroup()) { + return requireGroupId().getDecodedId(); + } else if (isCallLink()) { + return requireCallLinkRoomId().encodeForProto().toByteArray(); + } else if (isIndividual()) { + return requireServiceId().toByteArray(); + } else { + throw new IllegalStateException("Recipient does not support conversation id"); + } + } + public PhoneNumberSharingState getPhoneNumberSharing() { return phoneNumberSharing; } @@ -1387,7 +1428,9 @@ public class Recipient { Objects.equals(badges, other.badges) && isActiveGroup == other.isActiveGroup && Objects.equals(callLinkRoomId, other.callLinkRoomId) && - phoneNumberSharing == other.phoneNumberSharing; + phoneNumberSharing == other.phoneNumberSharing && + Objects.equals(nickname, other.nickname) && + Objects.equals(note, other.note); } private static boolean allContentsAreTheSame(@NonNull List a, @NonNull List b) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.kt index 4244012a2e..cb592b57a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.kt @@ -81,7 +81,9 @@ class RecipientDetails private constructor( @JvmField val needsPniSignature: Boolean, @JvmField val callLinkRoomId: CallLinkRoomId?, @JvmField val groupRecord: Optional, - @JvmField val phoneNumberSharing: PhoneNumberSharingState + @JvmField val phoneNumberSharing: PhoneNumberSharingState, + @JvmField val nickname: ProfileName, + @JvmField val note: String? ) { @VisibleForTesting @@ -146,7 +148,9 @@ class RecipientDetails private constructor( needsPniSignature = record.needsPniSignature, callLinkRoomId = record.callLinkRoomId, groupRecord = groupRecord, - phoneNumberSharing = record.phoneNumberSharing + phoneNumberSharing = record.phoneNumberSharing, + nickname = record.nickname, + note = record.note ) companion object { @@ -275,7 +279,9 @@ class RecipientDetails private constructor( isActiveGroup = false, callLinkRoomId = null, groupRecord = Optional.empty(), - phoneNumberSharing = PhoneNumberSharingState.UNKNOWN + phoneNumberSharing = PhoneNumberSharingState.UNKNOWN, + nickname = ProfileName.EMPTY, + note = "" ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt index 4db9e45db2..b2a5e1639b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt @@ -10,6 +10,7 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,9 +29,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -44,6 +48,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.nicknames.ViewNoteSheet import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -88,23 +93,26 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { Content( model = AboutModel( isSelf = recipient.get().isSelf, - hasAvatar = recipient.get().profileAvatarFileDetails.hasFile(), displayName = recipient.get().getDisplayName(requireContext()), shortName = recipient.get().getShortDisplayName(requireContext()), + profileName = recipient.get().profileName.toString(), about = recipient.get().about, verified = verified, + hasAvatar = recipient.get().profileAvatarFileDetails.hasFile(), recipientForAvatar = recipient.get(), formattedE164 = if (recipient.get().hasE164() && recipient.get().shouldShowE164()) { PhoneNumberFormatter.get(requireContext()).prettyPrintFormat(recipient.get().requireE164()) } else { null }, - groupsInCommon = groupsInCommonCount, profileSharing = recipient.get().isProfileSharing, - systemContact = recipient.get().isSystemContact + systemContact = recipient.get().isSystemContact, + groupsInCommon = groupsInCommonCount, + note = recipient.get().note ?: "" ), onClickSignalConnections = this::openSignalConnectionsSheet, - onAvatarClicked = this::openProfilePhotoViewer + onAvatarClicked = this::openProfilePhotoViewer, + onNoteClicked = this::openNoteSheet ) } } @@ -117,12 +125,18 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { private fun openProfilePhotoViewer() { startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId)) } + + private fun openNoteSheet() { + dismiss() + ViewNoteSheet.create(recipientId).show(parentFragmentManager, null) + } } private data class AboutModel( val isSelf: Boolean, val displayName: String, val shortName: String, + val profileName: String, val about: String?, val verified: Boolean, val hasAvatar: Boolean, @@ -130,14 +144,16 @@ private data class AboutModel( val formattedE164: String?, val profileSharing: Boolean, val systemContact: Boolean, - val groupsInCommon: Int + val groupsInCommon: Int, + val note: String ) @Composable private fun Content( model: AboutModel, onClickSignalConnections: () -> Unit, - onAvatarClicked: () -> Unit + onAvatarClicked: () -> Unit, + onNoteClicked: () -> Unit ) { Box( contentAlignment = Alignment.Center, @@ -175,17 +191,24 @@ private fun Content( AboutRow( startIcon = painterResource(R.drawable.symbol_person_24), - text = model.displayName, + text = if (!model.isSelf && model.displayName.isNotBlank() && model.profileName.isNotBlank() && model.displayName != model.profileName) { + stringResource(id = R.string.AboutSheet__user_set_display_name_and_profile_name, model.displayName, model.profileName) + } else { + model.displayName + }, modifier = Modifier.fillMaxWidth() ) if (model.about.isNotNullOrBlank()) { + val textColor = LocalContentColor.current + AboutRow( startIcon = painterResource(R.drawable.symbol_edit_24), text = { Row { AndroidView(factory = ::EmojiTextView) { it.text = model.about + it.setTextColor(textColor.toArgb()) TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge) } @@ -255,6 +278,16 @@ private fun Content( modifier = Modifier.fillMaxWidth() ) + if (model.note.isNotBlank()) { + AboutRow( + startIcon = painterResource(id = R.drawable.symbol_note_light_24), + text = model.note, + modifier = Modifier.fillMaxWidth(), + endIcon = painterResource(id = R.drawable.symbol_chevron_right_compact_bold_16), + onClick = onNoteClicked + ) + } + Spacer(modifier = Modifier.size(26.dp)) } } @@ -272,7 +305,10 @@ private fun AboutRow( text = { Text( text = text, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, false) ) }, modifier = modifier, @@ -284,7 +320,7 @@ private fun AboutRow( @Composable private fun AboutRow( startIcon: Painter, - text: @Composable () -> Unit, + text: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, endIcon: Painter? = null, onClick: (() -> Unit)? = null @@ -337,19 +373,82 @@ private fun ContentPreviewDefault() { Content( model = AboutModel( isSelf = false, - hasAvatar = true, displayName = "Peter Parker", shortName = "Peter", + profileName = "Peter Parker", about = "Photographer for the Daily Bugle.", verified = true, + hasAvatar = true, recipientForAvatar = Recipient.UNKNOWN, formattedE164 = "(123) 456-7890", profileSharing = true, systemContact = true, - groupsInCommon = 0 + groupsInCommon = 0, + note = "GET ME SPIDERMAN BEFORE I BLOW A DANG GASKET" ), onClickSignalConnections = {}, - onAvatarClicked = {} + onAvatarClicked = {}, + onNoteClicked = {} + ) + } + } +} + +@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreviewWithUserSetDisplayName() { + SignalTheme { + Surface { + Content( + model = AboutModel( + isSelf = false, + displayName = "Amazing Spider-man", + shortName = "Spiderman", + profileName = "Peter Parker", + about = "Photographer for the Daily Bugle.", + verified = true, + hasAvatar = true, + recipientForAvatar = Recipient.UNKNOWN, + formattedE164 = "(123) 456-7890", + profileSharing = true, + systemContact = true, + groupsInCommon = 0, + note = "Weird Things Happen To Me All The Time." + ), + onClickSignalConnections = {}, + onAvatarClicked = {}, + onNoteClicked = {} + ) + } + } +} + +@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreviewForSelf() { + SignalTheme { + Surface { + Content( + model = AboutModel( + isSelf = true, + displayName = "Amazing Spider-man", + shortName = "Spiderman", + profileName = "Peter Parker", + about = "Photographer for the Daily Bugle.", + verified = true, + hasAvatar = true, + recipientForAvatar = Recipient.UNKNOWN, + formattedE164 = "(123) 456-7890", + profileSharing = true, + systemContact = true, + groupsInCommon = 0, + note = "Weird Things Happen To Me All The Time." + ), + onClickSignalConnections = {}, + onAvatarClicked = {}, + onNoteClicked = {} ) } } @@ -364,19 +463,22 @@ private fun ContentPreviewInContactsNotProfileSharing() { Content( model = AboutModel( isSelf = false, - hasAvatar = true, displayName = "Peter Parker", shortName = "Peter", + profileName = "Peter Parker", about = "Photographer for the Daily Bugle.", verified = false, + hasAvatar = true, recipientForAvatar = Recipient.UNKNOWN, formattedE164 = null, profileSharing = false, systemContact = true, - groupsInCommon = 3 + groupsInCommon = 3, + note = "GET ME SPIDER MAN" ), onClickSignalConnections = {}, - onAvatarClicked = {} + onAvatarClicked = {}, + onNoteClicked = {} ) } } @@ -391,19 +493,22 @@ private fun ContentPreviewGroupsInCommonNoE164() { Content( model = AboutModel( isSelf = false, - hasAvatar = true, displayName = "Peter Parker", shortName = "Peter", + profileName = "Peter Parker", about = "Photographer for the Daily Bugle.", verified = false, + hasAvatar = true, recipientForAvatar = Recipient.UNKNOWN, formattedE164 = null, profileSharing = true, systemContact = false, - groupsInCommon = 3 + groupsInCommon = 3, + note = "GET ME SPIDERMAN" ), onClickSignalConnections = {}, - onAvatarClicked = {} + onAvatarClicked = {}, + onNoteClicked = {} ) } } @@ -418,19 +523,22 @@ private fun ContentPreviewNotAConnection() { Content( model = AboutModel( isSelf = false, - hasAvatar = true, displayName = "Peter Parker", shortName = "Peter", + profileName = "Peter Parker", about = "Photographer for the Daily Bugle.", verified = false, + hasAvatar = true, recipientForAvatar = Recipient.UNKNOWN, formattedE164 = null, profileSharing = false, systemContact = false, - groupsInCommon = 3 + groupsInCommon = 3, + note = "GET ME SPIDERMAN" ), onClickSignalConnections = {}, - onAvatarClicked = {} + onAvatarClicked = {}, + onNoteClicked = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index 5ad040e401..c54866e2d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -4,9 +4,6 @@ import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.DialogInterface; import android.content.Intent; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; @@ -17,6 +14,7 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; @@ -26,7 +24,6 @@ import androidx.lifecycle.ViewModelProvider; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import org.signal.core.util.DimensionUnit; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.avatar.view.AvatarView; @@ -37,7 +34,7 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.fonts.SignalSymbols; import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.nicknames.NicknameActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientExporter; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -45,6 +42,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; @@ -73,6 +71,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF private AvatarView avatar; private TextView fullName; private TextView about; + private TextView nickname; private TextView blockButton; private TextView unblockButton; private TextView addContactButton; @@ -91,6 +90,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF private ButtonStripPreference.ViewHolder buttonStripViewHolder; + private ActivityResultLauncher nicknameLauncher; + public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) { Recipient recipient = Recipient.resolved(recipientId); if (recipient.isSelf()) { @@ -126,6 +127,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF avatar = view.findViewById(R.id.rbs_recipient_avatar); fullName = view.findViewById(R.id.rbs_full_name); about = view.findViewById(R.id.rbs_about); + nickname = view.findViewById(R.id.rbs_nickname_button); blockButton = view.findViewById(R.id.rbs_block_button); unblockButton = view.findViewById(R.id.rbs_unblock_button); addContactButton = view.findViewById(R.id.rbs_add_contact_button); @@ -149,6 +151,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) { super.onViewCreated(fragmentView, savedInstanceState); + nicknameLauncher = registerForActivityResult(new NicknameActivity.Contract(), (b) -> {}); + Bundle arguments = requireArguments(); RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID))); GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)); @@ -213,6 +217,16 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF dismiss(); AboutSheet.create(recipient).show(getParentFragmentManager(), null); }); + + if (FeatureFlags.nicknames()) { + nickname.setVisibility(View.VISIBLE); + nickname.setOnClickListener(v -> { + nicknameLauncher.launch(new NicknameActivity.Args( + recipientId, + false + )); + }); + } } String aboutText = recipient.getCombinedAboutAndEmoji(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java index ba3e9e401a..8e7ff81137 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java @@ -193,6 +193,8 @@ public final class RegistrationRepository { TextSecurePreferences.setMultiDevice(context, true); } + ApplicationDependencies.resetProtocolStores(); + ApplicationDependencies.getProtocolStore().aci().sessions().archiveAllSessions(); ApplicationDependencies.getProtocolStore().pni().sessions().archiveAllSessions(); SenderKeyUtil.clearAllState(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt index b0b48a4914..74b28b01f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt @@ -1,11 +1,17 @@ package org.thoughtcrime.securesms.registration.secondary import okio.ByteString.Companion.toByteString +import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.util.ByteUtil import org.thoughtcrime.securesms.devicelist.protos.DeviceName import java.nio.charset.Charset +import java.security.GeneralSecurityException +import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec @@ -16,6 +22,8 @@ import javax.crypto.spec.SecretKeySpec */ object DeviceNameCipher { + private val TAG = Log.tag(DeviceNameCipher::class.java) + private const val SYNTHETIC_IV_LENGTH = 16 @JvmStatic @@ -37,6 +45,54 @@ object DeviceNameCipher { ).encode() } + /** + * Decrypts a [DeviceName]. Returns null if data is invalid/undecryptable. + */ + @JvmStatic + fun decryptDeviceName(deviceName: DeviceName, identityKeyPair: IdentityKeyPair): ByteArray? { + if (deviceName.ephemeralPublic == null || deviceName.syntheticIv == null || deviceName.ciphertext == null) { + return null + } + + return try { + val syntheticIv = deviceName.syntheticIv.toByteArray() + val cipherText = deviceName.ciphertext.toByteArray() + val identityKey: ECPrivateKey = identityKeyPair.privateKey + val ephemeralPublic = Curve.decodePoint(deviceName.ephemeralPublic.toByteArray(), 0) + val masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey) + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val cipherKeyPart1 = mac.doFinal("cipher".toByteArray()) + + mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256")) + val cipherKey = mac.doFinal(syntheticIv) + + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + val plaintext = cipher.doFinal(cipherText) + + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val verificationPart1 = mac.doFinal("auth".toByteArray()) + + mac.init(SecretKeySpec(verificationPart1, "HmacSHA256")) + val verificationPart2 = mac.doFinal(plaintext) + val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16) + + if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { + throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.") + } + + plaintext + } catch (e: GeneralSecurityException) { + Log.w(TAG, "Failed to decrypt device name.", e) + null + } catch (e: InvalidKeyException) { + Log.w(TAG, "Failed to decrypt device name.", e) + null + } + } + private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray { val input = "cipher".toByteArray(Charset.forName("UTF-8")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index e22f54de2f..3e8e1d6e14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.service.webrtc; import android.app.Application; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Build; import android.os.ResultReceiver; @@ -125,14 +126,14 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. @Nullable private final CallManager callManager; - private final Context context; - private final ExecutorService serviceExecutor; - private final Executor networkExecutor; - private final LockManager lockManager; + private final Context context; + private final ExecutorService serviceExecutor; + private final Executor networkExecutor; + private final LockManager lockManager; private WebRtcServiceState serviceState; private RxStore ephemeralStateStore; - private boolean needsToSetSelfUuid = true; + private boolean needsToSetSelfUuid = true; private RxStore> linkPeekInfoStore; @@ -182,8 +183,8 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. } private void process(@NonNull ProcessAction action) { - Throwable t = new Throwable(); - String caller = t.getStackTrace().length > 1 ? t.getStackTrace()[1].getMethodName() : "unknown"; + Throwable t = new Throwable(); + String caller = t.getStackTrace().length > 1 ? t.getStackTrace()[1].getMethodName() : "unknown"; if (callManager == null) { Log.w(TAG, "Unable to process action, call manager is not initialized"); @@ -1152,6 +1153,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. return new SignalCallLinkManager(Objects.requireNonNull(callManager)); } + public void relaunchPipOnForeground() { + ApplicationDependencies.getAppForegroundObserver().addListener(new RelaunchListener(ApplicationDependencies.getAppForegroundObserver().isForegrounded())); + } + private void processSendMessageFailureWithChangeDetection(@NonNull RemotePeer remotePeer, @NonNull ProcessAction failureProcessAction) { @@ -1170,6 +1175,44 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. }); } + private class RelaunchListener implements AppForegroundObserver.Listener { + private boolean canRelaunch; + + public RelaunchListener(boolean isForegrounded) { + canRelaunch = !isForegrounded; + } + + @Override + public void onForeground() { + if (canRelaunch) { + if (isSystemPipEnabledAndAvailable()) { + process((s, p) -> { + WebRtcViewModel.State callState = s.getCallInfoState().getCallState(); + + if (callState.getInOngoingCall()) { + Intent intent = new Intent(context, WebRtcCallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(WebRtcCallActivity.EXTRA_LAUNCH_IN_PIP, true); + context.startActivity(intent); + } + + return s; + }); + } + ApplicationDependencies.getAppForegroundObserver().removeListener(this); + } + } + + @Override + public void onBackground() { + canRelaunch = true; + } + + private boolean isSystemPipEnabledAndAvailable() { + return Build.VERSION.SDK_INT >= 26 && context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); + } + } + interface ProcessAction { @NonNull WebRtcServiceState process(@NonNull WebRtcServiceState currentState, @NonNull WebRtcActionProcessor processor); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java index 4dbef34281..47d1e7aa6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java @@ -64,11 +64,6 @@ public final class StickerSearchRepository { return out; } - public @NonNull Single getStickerFeatureAvailability() { - return Single.fromCallable(this::getStickerFeatureAvailabilitySync) - .observeOn(Schedulers.io()); - } - public void getStickerFeatureAvailability(@NonNull Callback callback) { SignalExecutors.BOUNDED.execute(() -> { callback.onResult(getStickerFeatureAvailabilitySync()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java index d876af4f00..cf49b33724 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java @@ -157,6 +157,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor