mirror of
https://github.com/oxen-io/session-android.git
synced 2025-04-16 22:11:26 +00:00
Merge branch 'dev' of https://github.com/oxen-io/session-android into refactor_clean_0
This commit is contained in:
commit
c138f20be5
@ -493,7 +493,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
|
|
||||||
DatabaseFactory.getLokiThreadDatabase(this).setDelegate(this);
|
DatabaseFactory.getLokiThreadDatabase(this).setDelegate(this);
|
||||||
|
|
||||||
inputPanel.setHint("Message");
|
inputPanel.setHint(getResources().getString(R.string.ConversationActivity_message));
|
||||||
|
|
||||||
updateSessionRestoreBanner();
|
updateSessionRestoreBanner();
|
||||||
|
|
||||||
@ -2500,8 +2500,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||||
if (recipient == null) {
|
if (recipient == null) {
|
||||||
titleTextView.setText("Compose");
|
titleTextView.setText("Compose");
|
||||||
} else if (recipient.getAddress().toString().toLowerCase() == userPublicKey) {
|
} else if (recipient.getAddress().toString().toLowerCase().equals(userPublicKey)) {
|
||||||
titleTextView.setText("Note to Self");
|
titleTextView.setText(getResources().getString(R.string.note_to_self));
|
||||||
} else {
|
} else {
|
||||||
boolean hasName = (recipient.getName() != null && !recipient.getName().isEmpty());
|
boolean hasName = (recipient.getName() != null && !recipient.getName().isEmpty());
|
||||||
titleTextView.setText(hasName ? recipient.getName() : recipient.getAddress().toString());
|
titleTextView.setText(hasName ? recipient.getName() : recipient.getAddress().toString());
|
||||||
|
@ -280,18 +280,15 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
ClosedGroupsProtocolV2.explicitLeave(this, groupPublicKey!!)
|
ClosedGroupsProtocolV2.explicitLeave(this, groupPublicKey!!)
|
||||||
} else {
|
} else {
|
||||||
task {
|
task {
|
||||||
val name =
|
if (hasNameChanged) {
|
||||||
if (hasNameChanged) ClosedGroupsProtocolV2.explicitNameChange(this@EditClosedGroupActivity,groupPublicKey!!,name)
|
ClosedGroupsProtocolV2.explicitNameChange(this@EditClosedGroupActivity, groupPublicKey!!, name)
|
||||||
else Promise.of(Unit)
|
}
|
||||||
name.get()
|
|
||||||
members.filterNot { it in originalMembers }.let { adds ->
|
members.filterNot { it in originalMembers }.let { adds ->
|
||||||
if (adds.isNotEmpty()) ClosedGroupsProtocolV2.explicitAddMembers(this@EditClosedGroupActivity, groupPublicKey!!, adds.map { it.address.serialize() })
|
if (adds.isNotEmpty()) ClosedGroupsProtocolV2.explicitAddMembers(this@EditClosedGroupActivity, groupPublicKey!!, adds.map { it.address.serialize() })
|
||||||
else Promise.of(Unit)
|
}
|
||||||
}.get()
|
|
||||||
originalMembers.filterNot { it in members }.let { removes ->
|
originalMembers.filterNot { it in members }.let { removes ->
|
||||||
if (removes.isNotEmpty()) ClosedGroupsProtocolV2.explicitRemoveMembers(this@EditClosedGroupActivity, groupPublicKey!!, removes.map { it.address.serialize() })
|
if (removes.isNotEmpty()) ClosedGroupsProtocolV2.explicitRemoveMembers(this@EditClosedGroupActivity, groupPublicKey!!, removes.map { it.address.serialize() })
|
||||||
else Promise.of(Unit)
|
}
|
||||||
}.get()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
promise.successUi {
|
promise.successUi {
|
||||||
|
@ -89,8 +89,8 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode))
|
val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode))
|
||||||
getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode)
|
getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode)
|
||||||
}
|
}
|
||||||
val youRow = getPathRow("You", null, LineView.Location.Top, 1000, dotAnimationRepeatInterval)
|
val youRow = getPathRow(resources.getString(R.string.activity_path_device_row_title), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval)
|
||||||
val destinationRow = getPathRow("Destination", null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval)
|
val destinationRow = getPathRow(resources.getString(R.string.activity_path_destination_row_title), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval)
|
||||||
val rows = listOf( youRow ) + pathRows + listOf( destinationRow )
|
val rows = listOf( youRow ) + pathRows + listOf( destinationRow )
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
pathRowsContainer.addView(row)
|
pathRowsContainer.addView(row)
|
||||||
|
@ -27,7 +27,10 @@ import org.session.libsignal.utilities.Hex
|
|||||||
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Parameters, private val destination: String, private val kind: Kind, private val sentTime: Long) : BaseJob(parameters) {
|
class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Parameters,
|
||||||
|
private val destination: String,
|
||||||
|
private val kind: Kind,
|
||||||
|
private val sentTime: Long) : BaseJob(parameters) {
|
||||||
|
|
||||||
sealed class Kind {
|
sealed class Kind {
|
||||||
class New(val publicKey: ByteArray, val name: String, val encryptionKeyPair: ECKeyPair, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
class New(val publicKey: ByteArray, val name: String, val encryptionKeyPair: ECKeyPair, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
||||||
@ -36,7 +39,7 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
class RemoveMembers(val members: Collection<ByteArray>) : Kind()
|
class RemoveMembers(val members: Collection<ByteArray>) : Kind()
|
||||||
class AddMembers(val members: Collection<ByteArray>) : Kind()
|
class AddMembers(val members: Collection<ByteArray>) : Kind()
|
||||||
class NameChange(val name: String) : Kind()
|
class NameChange(val name: String) : Kind()
|
||||||
class EncryptionKeyPair(val wrappers: Collection<KeyPairWrapper>) : Kind() // The new encryption key pair encrypted for each member individually
|
class EncryptionKeyPair(val wrappers: Collection<KeyPairWrapper>, val targetUser: String?) : Kind() // The new encryption key pair encrypted for each member individually
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -116,6 +119,7 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
builder.putString("kind", "EncryptionKeyPair")
|
builder.putString("kind", "EncryptionKeyPair")
|
||||||
val wrappers = kind.wrappers.joinToString(" - ") { Json.encodeToString(it) }
|
val wrappers = kind.wrappers.joinToString(" - ") { Json.encodeToString(it) }
|
||||||
builder.putString("wrappers", wrappers)
|
builder.putString("wrappers", wrappers)
|
||||||
|
builder.putString("targetUser", kind.targetUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return builder.build()
|
return builder.build()
|
||||||
@ -146,7 +150,8 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
}
|
}
|
||||||
"EncryptionKeyPair" -> {
|
"EncryptionKeyPair" -> {
|
||||||
val wrappers: Collection<KeyPairWrapper> = data.getString("wrappers").split(" - ").map { Json.decodeFromString(it) }
|
val wrappers: Collection<KeyPairWrapper> = data.getString("wrappers").split(" - ").map { Json.decodeFromString(it) }
|
||||||
kind = Kind.EncryptionKeyPair(wrappers)
|
val targetUser = data.getString("targetUser")
|
||||||
|
kind = Kind.EncryptionKeyPair(wrappers, targetUser)
|
||||||
}
|
}
|
||||||
"RemoveMembers" -> {
|
"RemoveMembers" -> {
|
||||||
val members = data.getString("members").split(" - ").map { Hex.fromStringCondensed(it) }
|
val members = data.getString("members").split(" - ").map { Hex.fromStringCondensed(it) }
|
||||||
@ -170,6 +175,11 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override fun onRun() {
|
public override fun onRun() {
|
||||||
|
val sendDestination = if (kind is Kind.EncryptionKeyPair && kind.targetUser != null) {
|
||||||
|
kind.targetUser
|
||||||
|
} else {
|
||||||
|
destination
|
||||||
|
}
|
||||||
val contentMessage = SignalServiceProtos.Content.newBuilder()
|
val contentMessage = SignalServiceProtos.Content.newBuilder()
|
||||||
val dataMessage = DataMessage.newBuilder()
|
val dataMessage = DataMessage.newBuilder()
|
||||||
val closedGroupUpdate = DataMessage.ClosedGroupControlMessage.newBuilder()
|
val closedGroupUpdate = DataMessage.ClosedGroupControlMessage.newBuilder()
|
||||||
@ -193,6 +203,9 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
is Kind.EncryptionKeyPair -> {
|
is Kind.EncryptionKeyPair -> {
|
||||||
closedGroupUpdate.type = DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR
|
closedGroupUpdate.type = DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR
|
||||||
closedGroupUpdate.addAllWrappers(kind.wrappers.map { it.toProto() })
|
closedGroupUpdate.addAllWrappers(kind.wrappers.map { it.toProto() })
|
||||||
|
if (kind.targetUser != null) {
|
||||||
|
closedGroupUpdate.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(destination))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Kind.Leave -> {
|
Kind.Leave -> {
|
||||||
closedGroupUpdate.type = DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT
|
closedGroupUpdate.type = DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT
|
||||||
@ -214,8 +227,8 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
contentMessage.dataMessage = dataMessage.build()
|
contentMessage.dataMessage = dataMessage.build()
|
||||||
val serializedContentMessage = contentMessage.build().toByteArray()
|
val serializedContentMessage = contentMessage.build().toByteArray()
|
||||||
val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender()
|
val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender()
|
||||||
val address = SignalServiceAddress(destination)
|
val address = SignalServiceAddress(sendDestination)
|
||||||
val recipient = recipient(context, destination)
|
val recipient = recipient(context, sendDestination)
|
||||||
val udAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient)
|
val udAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient)
|
||||||
val ttl = when (kind) {
|
val ttl = when (kind) {
|
||||||
is Kind.EncryptionKeyPair -> 4 * 24 * 60 * 60 * 1000
|
is Kind.EncryptionKeyPair -> 4 * 24 * 60 * 60 * 1000
|
||||||
@ -227,7 +240,7 @@ class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Paramete
|
|||||||
sentTime, serializedContentMessage, false, ttl,
|
sentTime, serializedContentMessage, false, ttl,
|
||||||
true, false, false, Optional.absent())
|
true, false, false, Optional.absent())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d("Loki", "Failed to send closed group update message to: $destination due to error: $e.")
|
Log.d("Loki", "Failed to send closed group update message to: $sendDestination due to error: $e.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import android.util.Log
|
|||||||
import com.google.protobuf.ByteString
|
import com.google.protobuf.ByteString
|
||||||
import nl.komponents.kovenant.Promise
|
import nl.komponents.kovenant.Promise
|
||||||
import nl.komponents.kovenant.deferred
|
import nl.komponents.kovenant.deferred
|
||||||
import nl.komponents.kovenant.task
|
|
||||||
import org.session.libsignal.libsignal.ecc.Curve
|
import org.session.libsignal.libsignal.ecc.Curve
|
||||||
import org.session.libsignal.libsignal.ecc.DjbECPrivateKey
|
import org.session.libsignal.libsignal.ecc.DjbECPrivateKey
|
||||||
import org.session.libsignal.libsignal.ecc.DjbECPublicKey
|
import org.session.libsignal.libsignal.ecc.DjbECPublicKey
|
||||||
@ -130,232 +129,118 @@ object ClosedGroupsProtocolV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun explicitAddMembers(context: Context, groupPublicKey: String, membersToAdd: List<String>): Promise<Any, java.lang.Exception> {
|
fun explicitAddMembers(context: Context, groupPublicKey: String, membersToAdd: List<String>) {
|
||||||
return task {
|
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
||||||
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
|
||||||
if (group == null) {
|
|
||||||
Log.d("Loki", "Can't leave nonexistent closed group.")
|
|
||||||
return@task Error.NoThread
|
|
||||||
}
|
|
||||||
val updatedMembers = group.members.map { it.serialize() }.toSet() + membersToAdd
|
|
||||||
// Save the new group members
|
|
||||||
groupDB.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
|
|
||||||
val membersAsData = updatedMembers.map { Hex.fromStringCondensed(it) }
|
|
||||||
val newMembersAsData = membersToAdd.map { Hex.fromStringCondensed(it) }
|
|
||||||
val admins = group.admins.map { it.serialize() }
|
|
||||||
val adminsAsData = admins.map { Hex.fromStringCondensed(it) }
|
|
||||||
val sentTime = System.currentTimeMillis()
|
|
||||||
val encryptionKeyPair = pendingKeyPair.getOrElse(groupPublicKey) {
|
|
||||||
Optional.fromNullable(apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey))
|
|
||||||
}.orNull()
|
|
||||||
if (encryptionKeyPair == null) {
|
|
||||||
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
|
||||||
return@task Error.NoKeyPair
|
|
||||||
}
|
|
||||||
val name = group.title
|
|
||||||
// Send the update to the group
|
|
||||||
val memberUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.AddMembers(newMembersAsData)
|
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, memberUpdateKind, sentTime)
|
|
||||||
job.setContext(context)
|
|
||||||
job.onRun() // Run the job immediately
|
|
||||||
// Notify the user
|
|
||||||
val infoType = GroupContext.Type.UPDATE
|
|
||||||
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
|
||||||
insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime)
|
|
||||||
// Send closed group update messages to any new members individually
|
|
||||||
for (member in membersToAdd) {
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val closedGroupNewKind = ClosedGroupUpdateMessageSendJobV2.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, encryptionKeyPair, membersAsData, adminsAsData)
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val newMemberJob = ClosedGroupUpdateMessageSendJobV2(member, closedGroupNewKind, sentTime)
|
|
||||||
ApplicationContext.getInstance(context).jobManager.add(newMemberJob)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun explicitRemoveMembers(context: Context, groupPublicKey: String, membersToRemove: List<String>): Promise<Any, Exception> {
|
|
||||||
return task {
|
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
|
||||||
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
|
||||||
if (group == null) {
|
|
||||||
Log.d("Loki", "Can't leave nonexistent closed group.")
|
|
||||||
return@task Error.NoThread
|
|
||||||
}
|
|
||||||
val updatedMembers = group.members.map { it.serialize() }.toSet() - membersToRemove
|
|
||||||
// Save the new group members
|
|
||||||
groupDB.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
|
|
||||||
val removeMembersAsData = membersToRemove.map { Hex.fromStringCondensed(it) }
|
|
||||||
val admins = group.admins.map { it.serialize() }
|
|
||||||
val sentTime = System.currentTimeMillis()
|
|
||||||
val encryptionKeyPair = apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
|
|
||||||
if (encryptionKeyPair == null) {
|
|
||||||
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
|
||||||
return@task Error.NoKeyPair
|
|
||||||
}
|
|
||||||
if (membersToRemove.any { it in admins } && updatedMembers.isNotEmpty()) {
|
|
||||||
Log.d("Loki", "Can't remove admin from closed group unless the group is destroyed entirely.")
|
|
||||||
return@task Error.InvalidUpdate
|
|
||||||
}
|
|
||||||
val name = group.title
|
|
||||||
// Send the update to the group
|
|
||||||
val memberUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.RemoveMembers(removeMembersAsData)
|
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, memberUpdateKind, sentTime)
|
|
||||||
job.setContext(context)
|
|
||||||
job.onRun() // Run the job immediately
|
|
||||||
// Notify the user
|
|
||||||
val infoType = GroupContext.Type.UPDATE
|
|
||||||
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
|
||||||
insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime)
|
|
||||||
val isCurrentUserAdmin = admins.contains(userPublicKey)
|
|
||||||
if (isCurrentUserAdmin) {
|
|
||||||
generateAndSendNewEncryptionKeyPair(context, groupPublicKey, updatedMembers)
|
|
||||||
}
|
|
||||||
return@task Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun explicitNameChange(context: Context, groupPublicKey: String, newName: String): Promise<Unit, Exception> {
|
|
||||||
val deferred = deferred<Unit, Exception>()
|
|
||||||
ThreadUtils.queue {
|
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
|
||||||
val members = group.members.map { it.serialize() }.toSet()
|
|
||||||
val admins = group.admins.map { it.serialize() }
|
|
||||||
val sentTime = System.currentTimeMillis()
|
|
||||||
if (group == null) {
|
|
||||||
Log.d("Loki", "Can't leave nonexistent closed group.")
|
|
||||||
return@queue deferred.reject(Error.NoThread)
|
|
||||||
}
|
|
||||||
// Send the update to the group
|
|
||||||
val kind = ClosedGroupUpdateMessageSendJobV2.Kind.NameChange(newName)
|
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, kind, sentTime)
|
|
||||||
job.setContext(context)
|
|
||||||
job.onRun() // Run the job immediately
|
|
||||||
// Notify the user
|
|
||||||
val infoType = GroupContext.Type.UPDATE
|
|
||||||
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
|
||||||
insertOutgoingInfoMessage(context, groupID, infoType, newName, members, admins, threadID, sentTime)
|
|
||||||
// Update the group
|
|
||||||
groupDB.updateTitle(groupID, newName)
|
|
||||||
deferred.resolve(Unit)
|
|
||||||
}
|
|
||||||
return deferred.promise
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun leave(context: Context, groupPublicKey: String): Promise<Unit, Exception> {
|
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
val group = groupDB.getGroup(groupID).orNull()
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
Log.d("Loki", "Can't leave nonexistent closed group.")
|
Log.d("Loki", "Can't leave nonexistent closed group.")
|
||||||
return Promise.ofFail(Error.NoThread)
|
throw Error.NoThread
|
||||||
|
}
|
||||||
|
val updatedMembers = group.members.map { it.serialize() }.toSet() + membersToAdd
|
||||||
|
val membersAsData = updatedMembers.map { Hex.fromStringCondensed(it) }
|
||||||
|
val newMembersAsData = membersToAdd.map { Hex.fromStringCondensed(it) }
|
||||||
|
val admins = group.admins.map { it.serialize() }
|
||||||
|
val adminsAsData = admins.map { Hex.fromStringCondensed(it) }
|
||||||
|
val sentTime = System.currentTimeMillis()
|
||||||
|
val encryptionKeyPair = pendingKeyPair.getOrElse(groupPublicKey) {
|
||||||
|
Optional.fromNullable(apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey))
|
||||||
|
}.orNull()
|
||||||
|
if (encryptionKeyPair == null) {
|
||||||
|
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
||||||
|
throw Error.NoKeyPair
|
||||||
}
|
}
|
||||||
val name = group.title
|
val name = group.title
|
||||||
val oldMembers = group.members.map { it.serialize() }.toSet()
|
// Send the update to the group
|
||||||
val newMembers: Set<String>
|
val memberUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.AddMembers(newMembersAsData)
|
||||||
val isCurrentUserAdmin = group.admins.map { it.toString() }.contains(userPublicKey)
|
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, memberUpdateKind, sentTime)
|
||||||
if (!isCurrentUserAdmin) {
|
job.setContext(context)
|
||||||
newMembers = oldMembers.minus(userPublicKey)
|
job.onRun() // Run the job immediately
|
||||||
} else {
|
// Save the new group members
|
||||||
newMembers = setOf() // If the admin leaves the group is destroyed
|
groupDB.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
|
||||||
|
// Notify the user
|
||||||
|
val infoType = GroupContext.Type.UPDATE
|
||||||
|
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
||||||
|
insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime)
|
||||||
|
// Send closed group update messages to any new members individually
|
||||||
|
for (member in membersToAdd) {
|
||||||
|
@Suppress("NAME_SHADOWING")
|
||||||
|
val closedGroupNewKind = ClosedGroupUpdateMessageSendJobV2.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, encryptionKeyPair, membersAsData, adminsAsData)
|
||||||
|
@Suppress("NAME_SHADOWING")
|
||||||
|
val newMemberJob = ClosedGroupUpdateMessageSendJobV2(member, closedGroupNewKind, sentTime)
|
||||||
|
ApplicationContext.getInstance(context).jobManager.add(newMemberJob)
|
||||||
}
|
}
|
||||||
return update(context, groupPublicKey, newMembers, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(context: Context, groupPublicKey: String, members: Collection<String>, name: String): Promise<Unit, Exception> {
|
@JvmStatic
|
||||||
val deferred = deferred<Unit, Exception>()
|
fun explicitRemoveMembers(context: Context, groupPublicKey: String, membersToRemove: List<String>) {
|
||||||
ThreadUtils.queue {
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
||||||
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
val group = groupDB.getGroup(groupID).orNull()
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
if (group == null) {
|
||||||
if (group == null) {
|
Log.d("Loki", "Can't leave nonexistent closed group.")
|
||||||
Log.d("Loki", "Can't update nonexistent closed group.")
|
throw Error.NoThread
|
||||||
return@queue deferred.reject(Error.NoThread)
|
}
|
||||||
}
|
val updatedMembers = group.members.map { it.serialize() }.toSet() - membersToRemove
|
||||||
val sentTime = System.currentTimeMillis()
|
val removeMembersAsData = membersToRemove.map { Hex.fromStringCondensed(it) }
|
||||||
val oldMembers = group.members.map { it.serialize() }.toSet()
|
val admins = group.admins.map { it.serialize() }
|
||||||
val newMembers = members.minus(oldMembers)
|
val sentTime = System.currentTimeMillis()
|
||||||
val membersAsData = members.map { Hex.fromStringCondensed(it) }
|
val encryptionKeyPair = apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
|
||||||
val admins = group.admins.map { it.serialize() }
|
if (encryptionKeyPair == null) {
|
||||||
val adminsAsData = admins.map { Hex.fromStringCondensed(it) }
|
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
||||||
val encryptionKeyPair = apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
|
throw Error.NoKeyPair
|
||||||
if (encryptionKeyPair == null) {
|
}
|
||||||
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
if (membersToRemove.any { it in admins } && updatedMembers.isNotEmpty()) {
|
||||||
return@queue deferred.reject(Error.NoKeyPair)
|
Log.d("Loki", "Can't remove admin from closed group unless the group is destroyed entirely.")
|
||||||
}
|
throw Error.InvalidUpdate
|
||||||
val removedMembers = oldMembers.minus(members)
|
}
|
||||||
if (removedMembers.contains(admins.first()) && members.isNotEmpty()) {
|
val name = group.title
|
||||||
Log.d("Loki", "Can't remove admin from closed group unless the group is destroyed entirely.")
|
// Send the update to the group
|
||||||
return@queue deferred.reject(Error.InvalidUpdate)
|
val memberUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.RemoveMembers(removeMembersAsData)
|
||||||
}
|
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, memberUpdateKind, sentTime)
|
||||||
val isUserLeaving = removedMembers.contains(userPublicKey)
|
job.setContext(context)
|
||||||
if (isUserLeaving && members.isNotEmpty()) {
|
job.onRun() // Run the job immediately
|
||||||
if (removedMembers.count() != 1 || newMembers.isNotEmpty()) {
|
// Save the new group members
|
||||||
Log.d("Loki", "Can't remove self and add or remove others simultaneously.")
|
groupDB.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
|
||||||
return@queue deferred.reject(Error.InvalidUpdate)
|
// Notify the user
|
||||||
}
|
val infoType = GroupContext.Type.UPDATE
|
||||||
}
|
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
||||||
// Send the update to the group
|
insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime)
|
||||||
@Suppress("NAME_SHADOWING")
|
val isCurrentUserAdmin = admins.contains(userPublicKey)
|
||||||
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.Update(name, membersAsData)
|
if (isCurrentUserAdmin) {
|
||||||
@Suppress("NAME_SHADOWING")
|
generateAndSendNewEncryptionKeyPair(context, groupPublicKey, updatedMembers)
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, closedGroupUpdateKind, sentTime)
|
|
||||||
job.setContext(context)
|
|
||||||
job.onRun() // Run the job immediately
|
|
||||||
if (isUserLeaving) {
|
|
||||||
// Remove the group private key and unsubscribe from PNs
|
|
||||||
apiDB.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
|
|
||||||
apiDB.removeClosedGroupPublicKey(groupPublicKey)
|
|
||||||
// Mark the group as inactive
|
|
||||||
groupDB.setActive(groupID, false)
|
|
||||||
groupDB.removeMember(groupID, Address.fromSerialized(userPublicKey))
|
|
||||||
// Notify the PN server
|
|
||||||
LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
|
|
||||||
} else {
|
|
||||||
// Generate and distribute a new encryption key pair if needed
|
|
||||||
val wasAnyUserRemoved = removedMembers.isNotEmpty()
|
|
||||||
val isCurrentUserAdmin = admins.contains(userPublicKey)
|
|
||||||
if (wasAnyUserRemoved && isCurrentUserAdmin) {
|
|
||||||
generateAndSendNewEncryptionKeyPair(context, groupPublicKey, members.minus(newMembers))
|
|
||||||
}
|
|
||||||
// Send closed group update messages to any new members individually
|
|
||||||
for (member in newMembers) {
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, encryptionKeyPair, membersAsData, adminsAsData)
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(member, closedGroupUpdateKind, sentTime)
|
|
||||||
ApplicationContext.getInstance(context).jobManager.add(job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update the group
|
|
||||||
groupDB.updateTitle(groupID, name)
|
|
||||||
if (!isUserLeaving) {
|
|
||||||
// The call below sets isActive to true, so if the user is leaving we have to use groupDB.remove(...) instead
|
|
||||||
groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) })
|
|
||||||
}
|
|
||||||
// Notify the user
|
|
||||||
val infoType = if (isUserLeaving) GroupContext.Type.QUIT else GroupContext.Type.UPDATE
|
|
||||||
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
|
||||||
insertOutgoingInfoMessage(context, groupID, infoType, name, members, admins, threadID, sentTime)
|
|
||||||
deferred.resolve(Unit)
|
|
||||||
}
|
}
|
||||||
return deferred.promise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateAndSendNewEncryptionKeyPair(context: Context, groupPublicKey: String, targetMembers: Collection<String>) {
|
@JvmStatic
|
||||||
|
fun explicitNameChange(context: Context, groupPublicKey: String, newName: String) {
|
||||||
|
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
||||||
|
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
||||||
|
val group = groupDB.getGroup(groupID).orNull()
|
||||||
|
val members = group.members.map { it.serialize() }.toSet()
|
||||||
|
val admins = group.admins.map { it.serialize() }
|
||||||
|
val sentTime = System.currentTimeMillis()
|
||||||
|
if (group == null) {
|
||||||
|
Log.d("Loki", "Can't leave nonexistent closed group.")
|
||||||
|
throw Error.NoThread
|
||||||
|
}
|
||||||
|
// Send the update to the group
|
||||||
|
val kind = ClosedGroupUpdateMessageSendJobV2.Kind.NameChange(newName)
|
||||||
|
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, kind, sentTime)
|
||||||
|
job.setContext(context)
|
||||||
|
job.onRun() // Run the job immediately
|
||||||
|
// Notify the user
|
||||||
|
val infoType = GroupContext.Type.UPDATE
|
||||||
|
val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false))
|
||||||
|
insertOutgoingInfoMessage(context, groupID, infoType, newName, members, admins, threadID, sentTime)
|
||||||
|
// Update the group
|
||||||
|
groupDB.updateTitle(groupID, newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateAndSendNewEncryptionKeyPair(context: Context, groupPublicKey: String, targetMembers: Collection<String>) {
|
||||||
// Prepare
|
// Prepare
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||||
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
val apiDB = DatabaseFactory.getLokiAPIDatabase(context)
|
||||||
@ -384,7 +269,7 @@ object ClosedGroupsProtocolV2 {
|
|||||||
pendingKeyPair[groupPublicKey] = Optional.absent()
|
pendingKeyPair[groupPublicKey] = Optional.absent()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendEncryptionKeyPair(context: Context, groupPublicKey: String, newKeyPair: ECKeyPair, targetMembers: Collection<String>, force: Boolean = true) {
|
private fun sendEncryptionKeyPair(context: Context, groupPublicKey: String, newKeyPair: ECKeyPair, targetMembers: Collection<String>, targetUser: String? = null, force: Boolean = true) {
|
||||||
val proto = SignalServiceProtos.KeyPair.newBuilder()
|
val proto = SignalServiceProtos.KeyPair.newBuilder()
|
||||||
proto.publicKey = ByteString.copyFrom(newKeyPair.publicKey.serialize().removing05PrefixIfNeeded())
|
proto.publicKey = ByteString.copyFrom(newKeyPair.publicKey.serialize().removing05PrefixIfNeeded())
|
||||||
proto.privateKey = ByteString.copyFrom(newKeyPair.privateKey.serialize())
|
proto.privateKey = ByteString.copyFrom(newKeyPair.privateKey.serialize())
|
||||||
@ -393,7 +278,7 @@ object ClosedGroupsProtocolV2 {
|
|||||||
val ciphertext = SessionProtocolImpl(context).encrypt(plaintext, publicKey)
|
val ciphertext = SessionProtocolImpl(context).encrypt(plaintext, publicKey)
|
||||||
ClosedGroupUpdateMessageSendJobV2.KeyPairWrapper(publicKey, ciphertext)
|
ClosedGroupUpdateMessageSendJobV2.KeyPairWrapper(publicKey, ciphertext)
|
||||||
}
|
}
|
||||||
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, ClosedGroupUpdateMessageSendJobV2.Kind.EncryptionKeyPair(wrappers), System.currentTimeMillis())
|
val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, ClosedGroupUpdateMessageSendJobV2.Kind.EncryptionKeyPair(wrappers, targetUser), System.currentTimeMillis())
|
||||||
if (force) {
|
if (force) {
|
||||||
job.setContext(context)
|
job.setContext(context)
|
||||||
job.onRun() // Run the job immediately
|
job.onRun() // Run the job immediately
|
||||||
@ -547,17 +432,17 @@ object ClosedGroupsProtocolV2 {
|
|||||||
Log.d("Loki", "Ignoring closed group info message for nonexistent or inactive group.")
|
Log.d("Loki", "Ignoring closed group info message for nonexistent or inactive group.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check common group update logic
|
||||||
if (!isValidGroupUpdate(group, sentTimestamp, senderPublicKey)) {
|
if (!isValidGroupUpdate(group, sentTimestamp, senderPublicKey)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val name = group.title
|
val name = group.title
|
||||||
// Check common group update logic
|
|
||||||
val members = group.members.map { it.serialize() }
|
val members = group.members.map { it.serialize() }
|
||||||
val admins = group.admins.map { it.serialize() }
|
val admins = group.admins.map { it.serialize() }
|
||||||
|
|
||||||
// Users that are part of this remove update
|
// Users that are part of this add update
|
||||||
val updateMembers = closedGroupUpdate.membersList.map { it.toByteArray().toHexString() }
|
val updateMembers = closedGroupUpdate.membersList.map { it.toByteArray().toHexString() }
|
||||||
// newMembers to save is old members minus removed members
|
// newMembers to save is old members plus members included in this update
|
||||||
val newMembers = members + updateMembers
|
val newMembers = members + updateMembers
|
||||||
groupDB.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
|
groupDB.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
|
||||||
|
|
||||||
@ -574,7 +459,9 @@ object ClosedGroupsProtocolV2 {
|
|||||||
if (encryptionKeyPair == null) {
|
if (encryptionKeyPair == null) {
|
||||||
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
Log.d("Loki", "Couldn't get encryption key pair for closed group.")
|
||||||
} else {
|
} else {
|
||||||
sendEncryptionKeyPair(context, groupPublicKey, encryptionKeyPair, newMembers, false)
|
for (user in updateMembers) {
|
||||||
|
sendEncryptionKeyPair(context, groupPublicKey, encryptionKeyPair, setOf(user), targetUser = user, force = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -629,7 +516,8 @@ object ClosedGroupsProtocolV2 {
|
|||||||
val updatedMemberList = members - senderPublicKey
|
val updatedMemberList = members - senderPublicKey
|
||||||
val userLeft = userPublicKey == senderPublicKey
|
val userLeft = userPublicKey == senderPublicKey
|
||||||
|
|
||||||
if (didAdminLeave || userLeft) {
|
// if the admin left, we left, or we are the only remaining member: remove the group
|
||||||
|
if (didAdminLeave || userLeft || updatedMemberList.size == 1) {
|
||||||
disableLocalGroupAndUnsubscribe(context, apiDB, groupPublicKey, groupDB, groupID, userPublicKey)
|
disableLocalGroupAndUnsubscribe(context, apiDB, groupPublicKey, groupDB, groupID, userPublicKey)
|
||||||
} else {
|
} else {
|
||||||
val isCurrentUserAdmin = admins.contains(userPublicKey)
|
val isCurrentUserAdmin = admins.contains(userPublicKey)
|
||||||
@ -736,7 +624,12 @@ object ClosedGroupsProtocolV2 {
|
|||||||
val userKeyPair = apiDB.getUserX25519KeyPair()
|
val userKeyPair = apiDB.getUserX25519KeyPair()
|
||||||
// Unwrap the message
|
// Unwrap the message
|
||||||
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
val groupDB = DatabaseFactory.getGroupDatabase(context)
|
||||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
val correctGroupPublicKey = when {
|
||||||
|
groupPublicKey.isNotEmpty() -> groupPublicKey
|
||||||
|
!closedGroupUpdate.publicKey.isEmpty -> closedGroupUpdate.publicKey.toByteArray().toHexString()
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val groupID = GroupUtil.doubleEncodeGroupID(correctGroupPublicKey)
|
||||||
val group = groupDB.getGroup(groupID).orNull()
|
val group = groupDB.getGroup(groupID).orNull()
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
Log.d("Loki", "Ignoring closed group encryption key pair message for nonexistent group.")
|
Log.d("Loki", "Ignoring closed group encryption key pair message for nonexistent group.")
|
||||||
@ -754,7 +647,7 @@ object ClosedGroupsProtocolV2 {
|
|||||||
val proto = SignalServiceProtos.KeyPair.parseFrom(plaintext)
|
val proto = SignalServiceProtos.KeyPair.parseFrom(plaintext)
|
||||||
val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removing05PrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray()))
|
val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removing05PrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray()))
|
||||||
// Store it
|
// Store it
|
||||||
apiDB.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey)
|
apiDB.addClosedGroupEncryptionKeyPair(keyPair, correctGroupPublicKey)
|
||||||
Log.d("Loki", "Received a new closed group encryption key pair")
|
Log.d("Loki", "Received a new closed group encryption key pair")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,6 +128,8 @@
|
|||||||
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Le destinataire n’est pas une adresse texto ou courriel valide !</string>
|
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Le destinataire n’est pas une adresse texto ou courriel valide !</string>
|
||||||
<string name="ConversationActivity_message_is_empty_exclamation">Le message est vide !</string>
|
<string name="ConversationActivity_message_is_empty_exclamation">Le message est vide !</string>
|
||||||
<string name="ConversationActivity_group_members">Membres du groupe</string>
|
<string name="ConversationActivity_group_members">Membres du groupe</string>
|
||||||
|
<string name="ConversationActivity_note_to_self">Note à mon intention</string>
|
||||||
|
<string name="ConversationActivity_message">Message</string>
|
||||||
<string name="ConversationActivity_invalid_recipient">Le destinataire est invalide !</string>
|
<string name="ConversationActivity_invalid_recipient">Le destinataire est invalide !</string>
|
||||||
<string name="ConversationActivity_added_to_home_screen">Ajouté à l’écran d’accueil</string>
|
<string name="ConversationActivity_added_to_home_screen">Ajouté à l’écran d’accueil</string>
|
||||||
<string name="ConversationActivity_calls_not_supported">Appels non pris en charge</string>
|
<string name="ConversationActivity_calls_not_supported">Appels non pris en charge</string>
|
||||||
|
@ -133,6 +133,8 @@
|
|||||||
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Адрес получателя не является ни номером телефона, ни адресом электронной почты.</string>
|
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Адрес получателя не является ни номером телефона, ни адресом электронной почты.</string>
|
||||||
<string name="ConversationActivity_message_is_empty_exclamation">Пустое сообщение!</string>
|
<string name="ConversationActivity_message_is_empty_exclamation">Пустое сообщение!</string>
|
||||||
<string name="ConversationActivity_group_members">Участники группы</string>
|
<string name="ConversationActivity_group_members">Участники группы</string>
|
||||||
|
<string name="ConversationActivity_note_to_self">Заметка для себя</string>
|
||||||
|
<string name="ConversationActivity_message">Соощение</string>
|
||||||
<string name="ConversationActivity_invalid_recipient">Неверный получатель!</string>
|
<string name="ConversationActivity_invalid_recipient">Неверный получатель!</string>
|
||||||
<string name="ConversationActivity_added_to_home_screen">Добавлено на главный экран</string>
|
<string name="ConversationActivity_added_to_home_screen">Добавлено на главный экран</string>
|
||||||
<string name="ConversationActivity_calls_not_supported">Звонки не поддерживаются</string>
|
<string name="ConversationActivity_calls_not_supported">Звонки не поддерживаются</string>
|
||||||
|
@ -151,6 +151,8 @@
|
|||||||
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Recipient is not a valid SMS or email address!</string>
|
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Recipient is not a valid SMS or email address!</string>
|
||||||
<string name="ConversationActivity_message_is_empty_exclamation">Message is empty!</string>
|
<string name="ConversationActivity_message_is_empty_exclamation">Message is empty!</string>
|
||||||
<string name="ConversationActivity_group_members">Group members</string>
|
<string name="ConversationActivity_group_members">Group members</string>
|
||||||
|
<string name="ConversationActivity_note_to_self">Note to self</string>
|
||||||
|
<string name="ConversationActivity_message">Message</string>
|
||||||
|
|
||||||
<string name="ConversationActivity_invalid_recipient">Invalid recipient!</string>
|
<string name="ConversationActivity_invalid_recipient">Invalid recipient!</string>
|
||||||
<string name="ConversationActivity_added_to_home_screen">Added to home screen</string>
|
<string name="ConversationActivity_added_to_home_screen">Added to home screen</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user