Merge branch 'master' into calls

# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java
#	app/src/main/res/values/strings.xml
#	app/src/main/res/values/styles.xml
#	libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt
#	libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt
#	libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt
#	libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java
This commit is contained in:
Harris
2022-03-17 16:49:35 +11:00
209 changed files with 10381 additions and 18113 deletions

View File

@@ -46,6 +46,11 @@ public class AvatarHelper {
return new File(avatarDirectory, new File(address.serialize()).getName());
}
public static boolean avatarFileExists(@NonNull Context context , @NonNull Address address) {
File avatarFile = getAvatarFile(context, address);
return avatarFile.exists();
}
public static void setAvatar(@NonNull Context context, @NonNull Address address, @Nullable byte[] data)
throws IOException
{

View File

@@ -5,10 +5,10 @@ import android.net.Uri
import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupV2
@@ -157,6 +157,9 @@ interface StorageProtocol {
*/
fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>): Long?
fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long)
fun insertMessageRequestResponse(response: MessageRequestResponse)
fun setRecipientApproved(recipient: Recipient, approved: Boolean)
fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean)
fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long)
fun conversationHasOutgoing(userPublicKey: String): Boolean
}

View File

@@ -60,19 +60,22 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
}
}
class Contact(var publicKey: String, var name: String, var profilePicture: String?, var profileKey: ByteArray?) {
class Contact(var publicKey: String, var name: String, var profilePicture: String?, var profileKey: ByteArray?, var isApproved: Boolean?, var isBlocked: Boolean?, var didApproveMe: Boolean?) {
internal constructor() : this("", "", null, null)
internal constructor() : this("", "", null, null, null, null, null)
companion object {
fun fromProto(proto: SignalServiceProtos.ConfigurationMessage.Contact): Contact? {
if (!proto.hasName() || !proto.hasProfileKey()) return null
if (!proto.hasName()) return null
val publicKey = proto.publicKey.toByteArray().toHexString()
val name = proto.name
val profilePicture = if (proto.hasProfilePicture()) proto.profilePicture else null
val profileKey = if (proto.hasProfileKey()) proto.profileKey.toByteArray() else null
return Contact(publicKey, name, profilePicture, profileKey)
val isApproved = if (proto.hasIsApproved()) proto.isApproved else null
val isBlocked = if (proto.hasIsBlocked()) proto.isBlocked else null
val didApproveMe = if (proto.hasDidApproveMe()) proto.didApproveMe else null
return Contact(publicKey, name, profilePicture, profileKey, isApproved, isBlocked, didApproveMe)
}
}
@@ -92,6 +95,18 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
if (profileKey != null) {
result.profileKey = ByteString.copyFrom(profileKey)
}
val isApproved = isApproved
if (isApproved != null) {
result.isApproved = isApproved
}
val isBlocked = isBlocked
if (isBlocked != null) {
result.isBlocked = isBlocked
}
val didApproveMe = didApproveMe
if (didApproveMe != null) {
result.didApproveMe = didApproveMe
}
return result.build()
}
}

View File

@@ -0,0 +1,33 @@
package org.session.libsession.messaging.messages.control
import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.utilities.Log
class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() {
override val isSelfSendValid: Boolean = true
override fun toProto(): SignalServiceProtos.Content? {
val messageRequestResponseProto = SignalServiceProtos.MessageRequestResponse.newBuilder()
.setIsApproved(isApproved)
return try {
SignalServiceProtos.Content.newBuilder()
.setMessageRequestResponse(messageRequestResponseProto.build())
.build()
} catch (e: Exception) {
Log.w(TAG, "Couldn't construct message request response proto from: $this")
null
}
}
companion object {
const val TAG = "MessageRequestResponse"
fun fromProto(proto: SignalServiceProtos.Content): MessageRequestResponse? {
val messageRequestResponseProto = if (proto.hasMessageRequestResponse()) proto.messageRequestResponse else return null
val isApproved = messageRequestResponseProto.isApproved
return MessageRequestResponse(isApproved)
}
}
}

View File

@@ -28,6 +28,7 @@ public class IncomingMediaMessage {
private final long expiresIn;
private final boolean expirationUpdate;
private final boolean unidentified;
private final boolean messageRequestResponse;
private final DataExtractionNotificationInfoMessage dataExtractionNotification;
private final QuoteModel quote;
@@ -42,6 +43,7 @@ public class IncomingMediaMessage {
long expiresIn,
boolean expirationUpdate,
boolean unidentified,
boolean messageRequestResponse,
Optional<String> body,
Optional<SignalServiceGroup> group,
Optional<List<SignalServiceAttachment>> attachments,
@@ -60,6 +62,7 @@ public class IncomingMediaMessage {
this.dataExtractionNotification = dataExtractionNotification.orNull();
this.quote = quote.orNull();
this.unidentified = unidentified;
this.messageRequestResponse = messageRequestResponse;
if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.INSTANCE.getEncodedId(group.get()));
else this.groupId = null;
@@ -78,7 +81,7 @@ public class IncomingMediaMessage {
Optional<List<LinkPreview>> linkPreviews)
{
return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, false,
false, Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent());
false, false, Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent());
}
public int getSubscriptionId() {
@@ -150,4 +153,8 @@ public class IncomingMediaMessage {
public boolean isUnidentified() {
return unidentified;
}
public boolean isMessageRequestResponse() {
return messageRequestResponse;
}
}

View File

@@ -95,6 +95,7 @@ object MessageReceiver {
ExpirationTimerUpdate.fromProto(proto) ?:
ConfigurationMessage.fromProto(proto) ?:
UnsendRequest.fromProto(proto) ?:
MessageRequestResponse.fromProto(proto) ?:
CallMessage.fromProto(proto) ?:
VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage
// Ignore self send if needed

View File

@@ -1,11 +1,19 @@
package org.session.libsession.messaging.sending_receiving
import android.text.TextUtils
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.*
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.messages.control.TypingIndicator
import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment
@@ -16,7 +24,12 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.*
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey
@@ -29,8 +42,7 @@ import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.toHexString
import java.security.MessageDigest
import java.util.*
import kotlin.collections.ArrayList
import java.util.LinkedList
internal fun MessageReceiver.isBlocked(publicKey: String): Boolean {
val context = MessagingModuleConfiguration.shared.context
@@ -47,6 +59,7 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content,
is DataExtractionNotification -> handleDataExtractionNotification(message)
is ConfigurationMessage -> handleConfigurationMessage(message)
is UnsendRequest -> handleUnsendRequest(message)
is MessageRequestResponse -> handleMessageRequestResponse(message)
is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID)
is CallMessage -> handleCallMessage(message)
}
@@ -119,13 +132,18 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
&& !TextSecurePreferences.shouldUpdateProfile(context, message.sentTimestamp!!)) return
val userPublicKey = storage.getUserPublicKey()
if (userPublicKey == null || message.sender != storage.getUserPublicKey()) return
val firstTimeSync = !TextSecurePreferences.getConfigurationMessageSynced(context)
TextSecurePreferences.setConfigurationMessageSynced(context, true)
TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!)
val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
for (closedGroup in message.closedGroups) {
if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) continue
handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name,
closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer)
if (firstTimeSync) {
val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
for (closedGroup in message.closedGroups) {
if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) continue
handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name,
closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer)
}
}
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL }
for (openGroup in message.openGroups) {
@@ -167,6 +185,10 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest) {
SSKEnvironment.shared.notificationManager.updateNotification(context)
}
}
fun handleMessageRequestResponse(message: MessageRequestResponse) {
MessagingModuleConfiguration.shared.storage.insertMessageRequestResponse(message)
}
//endregion
// region Visible Messages
@@ -174,28 +196,33 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS
val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context
val userPublicKey = storage.getUserPublicKey()
val messageSender: String? = message.sender
// Get or create thread
// FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet
// exist. This is intentional, but it's very non-obvious.
val threadID = storage.getOrCreateThreadIdFor(message.syncTarget
?: message.sender!!, message.groupPublicKey, openGroupID)
?: messageSender!!, message.groupPublicKey, openGroupID)
if (threadID < 0) {
// Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread
throw MessageReceiver.Error.NoThread
}
// Update profile if needed
val recipient = Recipient.from(context, Address.fromSerialized(messageSender!!), false)
val profile = message.profile
if (profile != null && userPublicKey != message.sender) {
if (profile != null && userPublicKey != messageSender) {
val profileManager = SSKEnvironment.shared.profileManager
val recipient = Recipient.from(context, Address.fromSerialized(message.sender!!), false)
val name = profile.displayName!!
if (name.isNotEmpty()) {
profileManager.setName(context, recipient, name)
}
val newProfileKey = profile.profileKey
if (newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true
&& (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey))) {
profileManager.setProfileKey(context, recipient, newProfileKey)
val needsProfilePicture = !AvatarHelper.avatarFileExists(context, Address.fromSerialized(messageSender))
val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true
val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey))
if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) {
profileManager.setProfileKey(context, recipient, newProfileKey!!)
profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN)
profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!)
}
@@ -276,6 +303,8 @@ private fun MessageReceiver.handleClosedGroupControlMessage(message: ClosedGroup
private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) {
val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return
val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false)
if (!recipient.isApproved) return
val groupPublicKey = kind.publicKey.toByteArray().toHexString()
val members = kind.members.map { it.toByteArray().toHexString() }
val admins = kind.admins.map { it.toByteArray().toHexString() }

View File

@@ -154,6 +154,8 @@ interface TextSecurePreferences {
fun setLastOpenDate()
fun hasSeenLinkPreviewSuggestionDialog(): Boolean
fun setHasSeenLinkPreviewSuggestionDialog()
fun hasHiddenMessageRequests(): Boolean
fun setHasHiddenMessageRequests()
fun setShownCallWarning(): Boolean
fun setShownCallNotification(): Boolean
fun isCallNotificationsEnabled(): Boolean
@@ -233,6 +235,7 @@ interface TextSecurePreferences {
const val CONFIGURATION_SYNCED = "pref_configuration_synced"
const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
const val LAST_OPEN_DATE = "pref_last_open_date"
const val HAS_HIDDEN_MESSAGE_REQUESTS = "pref_message_requests_hidden"
const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled"
const val SHOWN_CALL_WARNING = "pref_shown_call_warning" // call warning is user-facing warning of enabling calls
const val SHOWN_CALL_NOTIFICATION = "pref_shown_call_notification" // call notification is a promp to check privacy settings
@@ -879,6 +882,16 @@ interface TextSecurePreferences {
setBooleanPreference(context, "has_seen_link_preview_suggestion_dialog", true)
}
@JvmStatic
fun hasHiddenMessageRequests(context: Context): Boolean {
return getBooleanPreference(context, HAS_HIDDEN_MESSAGE_REQUESTS, false)
}
@JvmStatic
fun removeHasHiddenMessageRequests(context: Context) {
removePreference(context, HAS_HIDDEN_MESSAGE_REQUESTS)
}
@JvmStatic
fun setShownCallWarning(context: Context): Boolean {
val previousValue = getBooleanPreference(context, SHOWN_CALL_WARNING, false)
@@ -1473,6 +1486,14 @@ class AppTextSecurePreferences @Inject constructor(
return previousValue != setValue
}
override fun hasHiddenMessageRequests(): Boolean {
return getBooleanPreference(TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS, false)
}
override fun setHasHiddenMessageRequests() {
setBooleanPreference(TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS, true)
}
override fun clearAll() {
getDefaultSharedPreferences(context).edit().clear().commit()
}

View File

@@ -81,6 +81,8 @@ public class Recipient implements RecipientModifiedListener {
public long mutedUntil = 0;
public int notifyType = 0;
private boolean blocked = false;
private boolean approved = false;
private boolean approvedMe = false;
private VibrateState messageVibrate = VibrateState.DEFAULT;
private VibrateState callVibrate = VibrateState.DEFAULT;
private int expireMessages = 0;
@@ -141,6 +143,8 @@ public class Recipient implements RecipientModifiedListener {
this.callRingtone = stale.callRingtone;
this.mutedUntil = stale.mutedUntil;
this.blocked = stale.blocked;
this.approved = stale.approved;
this.approvedMe = stale.approvedMe;
this.messageVibrate = stale.messageVibrate;
this.callVibrate = stale.callVibrate;
this.expireMessages = stale.expireMessages;
@@ -169,6 +173,8 @@ public class Recipient implements RecipientModifiedListener {
this.callRingtone = details.get().callRingtone;
this.mutedUntil = details.get().mutedUntil;
this.blocked = details.get().blocked;
this.approved = details.get().approved;
this.approvedMe = details.get().approvedMe;
this.messageVibrate = details.get().messageVibrateState;
this.callVibrate = details.get().callVibrateState;
this.expireMessages = details.get().expireMessages;
@@ -570,6 +576,30 @@ public class Recipient implements RecipientModifiedListener {
notifyListeners();
}
public synchronized boolean isApproved() {
return approved;
}
public void setApproved(boolean approved) {
synchronized (this) {
this.approved = approved;
}
notifyListeners();
}
public synchronized boolean hasApprovedMe() {
return approvedMe;
}
public void setHasApprovedMe(boolean approvedMe) {
synchronized (this) {
this.approvedMe = approvedMe;
}
notifyListeners();
}
public synchronized VibrateState getMessageVibrate() {
return messageVibrate;
}
@@ -779,6 +809,8 @@ public class Recipient implements RecipientModifiedListener {
public static class RecipientSettings {
private final boolean blocked;
private final boolean approved;
private final boolean approvedMe;
private final long muteUntil;
private final int notifyType;
private final VibrateState messageVibrateState;
@@ -801,7 +833,7 @@ public class Recipient implements RecipientModifiedListener {
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
public RecipientSettings(boolean blocked, long muteUntil,
public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil,
int notifyType,
@NonNull VibrateState messageVibrateState,
@NonNull VibrateState callVibrateState,
@@ -824,6 +856,8 @@ public class Recipient implements RecipientModifiedListener {
boolean forceSmsSelection)
{
this.blocked = blocked;
this.approved = approved;
this.approvedMe = approvedMe;
this.muteUntil = muteUntil;
this.notifyType = notifyType;
this.messageVibrateState = messageVibrateState;
@@ -855,6 +889,14 @@ public class Recipient implements RecipientModifiedListener {
return blocked;
}
public boolean isApproved() {
return approved;
}
public boolean hasApprovedMe() {
return approvedMe;
}
public long getMuteUntil() {
return muteUntil;
}

View File

@@ -171,6 +171,8 @@ class RecipientProvider {
@Nullable final VibrateState messageVibrateState;
@Nullable final VibrateState callVibrateState;
final boolean blocked;
final boolean approved;
final boolean approvedMe;
final int expireMessages;
@NonNull final List<Recipient> participants;
@Nullable final String profileName;
@@ -201,6 +203,8 @@ class RecipientProvider {
this.messageVibrateState = settings != null ? settings.getMessageVibrateState() : null;
this.callVibrateState = settings != null ? settings.getCallVibrateState() : null;
this.blocked = settings != null && settings.isBlocked();
this.approved = settings != null && settings.isApproved();
this.approvedMe = settings != null && settings.hasApprovedMe();
this.expireMessages = settings != null ? settings.getExpireMessages() : 0;
this.participants = participants == null ? new LinkedList<>() : participants;
this.profileName = settings != null ? settings.getProfileName() : null;

View File

@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MessageRecord -->
<string name="MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported">Received a message encrypted using an old version of Session that is no longer supported. Please ask the sender to update to the most recent version and resend the message.</string>
<string name="MessageRecord_left_group">You have left the group.</string>
<string name="MessageRecord_you_updated_group">You updated the group.</string>
<string name="MessageRecord_you_created_a_new_group">You created a new group.</string>
<string name="MessageRecord_s_added_you_to_the_group">%1$s added you to the group.</string>
<string name="MessageRecord_you_renamed_the_group_to_s">You renamed the group to %1$s</string>
@@ -14,25 +12,15 @@
<string name="MessageRecord_s_removed_s_from_the_group">%1$s removed %2$s from the group.</string>
<string name="MessageRecord_you_were_removed_from_the_group">You were removed from the group.</string>
<string name="MessageRecord_you">You</string>
<string name="MessageRecord_you_called">You called</string>
<string name="MessageRecord_called_you">Contact called</string>
<string name="MessageRecord_missed_call">Missed call</string>
<string name="MessageRecord_s_updated_group">%s updated the group.</string>
<string name="MessageRecord_s_called_you">%s called you</string>
<string name="MessageRecord_called_s">Called %s</string>
<string name="MessageRecord_missed_call_from">Missed call from %s</string>
<string name="MessageRecord_s_joined_signal">%s is on Session!</string>
<string name="MessageRecord_you_disabled_disappearing_messages">You disabled disappearing messages.</string>
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s disabled disappearing messages.</string>
<string name="MessageRecord_you_set_disappearing_message_time_to_s">You set the disappearing message timer to %1$s</string>
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s set the disappearing message timer to %2$s</string>
<string name="MessageRecord_s_took_a_screenshot">%1$s took a screenshot.</string>
<string name="MessageRecord_media_saved_by_s">Media saved by %1$s.</string>
<string name="MessageRecord_your_safety_number_with_s_has_changed">Your safety number with %s has changed.</string>
<string name="MessageRecord_you_marked_your_safety_number_with_s_verified">You marked your safety number with %s verified</string>
<string name="MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device">You marked your safety number with %s verified from another device</string>
<string name="MessageRecord_you_marked_your_safety_number_with_s_unverified">You marked your safety number with %s unverified</string>
<string name="MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device">You marked your safety number with %s unverified from another device</string>
<!-- expiration -->
<string name="expiration_off">Off</string>