Merge pull request #39 from loki-project/multi-device-stage-2

[Stage 2] Multi device
This commit is contained in:
gmbnt
2019-11-15 16:25:56 +11:00
committed by GitHub
39 changed files with 1445 additions and 518 deletions

View File

@@ -0,0 +1,100 @@
package org.thoughtcrime.securesms.loki
import android.content.Context
import nl.komponents.kovenant.ui.successUi
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus
import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus
object FriendRequestHandler {
enum class ActionType { Sending, Sent, Failed }
@JvmStatic
fun updateFriendRequestState(context: Context, type: ActionType, messageId: Long, threadId: Long) {
if (threadId < 0) return
val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId) ?: return
if (!recipient.address.isPhone) { return }
val currentFriendStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId)
// Update thread status if we haven't sent a friend request before
if (currentFriendStatus != LokiThreadFriendRequestStatus.REQUEST_RECEIVED &&
currentFriendStatus != LokiThreadFriendRequestStatus.REQUEST_SENT &&
currentFriendStatus != LokiThreadFriendRequestStatus.FRIENDS
) {
val threadFriendStatus = when (type) {
ActionType.Sending -> LokiThreadFriendRequestStatus.REQUEST_SENDING
ActionType.Failed -> LokiThreadFriendRequestStatus.NONE
ActionType.Sent -> LokiThreadFriendRequestStatus.REQUEST_SENT
}
DatabaseFactory.getLokiThreadDatabase(context).setFriendRequestStatus(threadId, threadFriendStatus)
}
// Update message status
if (messageId >= 0) {
val messageDatabase = DatabaseFactory.getLokiMessageDatabase(context)
val friendRequestStatus = messageDatabase.getFriendRequestStatus(messageId)
if (type == ActionType.Sending) {
// We only want to update message status if we aren't friends with another of their devices
// This avoids spam in the ui where it would keep telling the user that they sent a friend request on every single message
isFriendsWithAnyLinkedDevice(context, recipient).successUi { isFriends ->
if (!isFriends && friendRequestStatus == LokiMessageFriendRequestStatus.NONE) {
messageDatabase.setFriendRequestStatus(messageId, LokiMessageFriendRequestStatus.REQUEST_SENDING)
}
}
} else if (friendRequestStatus != LokiMessageFriendRequestStatus.NONE) {
// Update the friend request status of the message if we have it
val messageFriendRequestStatus = when (type) {
ActionType.Failed -> LokiMessageFriendRequestStatus.REQUEST_FAILED
ActionType.Sent -> LokiMessageFriendRequestStatus.REQUEST_PENDING
else -> throw IllegalStateException()
}
messageDatabase.setFriendRequestStatus(messageId, messageFriendRequestStatus)
}
}
}
@JvmStatic
fun updateLastFriendRequestMessage(context: Context, threadId: Long, status: LokiMessageFriendRequestStatus) {
if (threadId < 0) { return }
val messages = DatabaseFactory.getSmsDatabase(context).getAllMessageIDs(threadId)
val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context)
val lastMessage = messages.find {
val friendRequestStatus = lokiMessageDatabase.getFriendRequestStatus(it)
friendRequestStatus == LokiMessageFriendRequestStatus.REQUEST_PENDING
} ?: return
DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(lastMessage, status)
}
@JvmStatic
fun receivedIncomingFriendRequestMessage(context: Context, threadId: Long) {
val smsMessageDatabase = DatabaseFactory.getSmsDatabase(context)
// We only want to update the last message status if we're not friends with any of their linked devices
// This ensures that we don't spam the UI with accept/decline messages
val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId) ?: return
if (!recipient.address.isPhone) { return }
isFriendsWithAnyLinkedDevice(context, recipient).successUi { isFriends ->
if (isFriends) { return@successUi }
// Since messages are forwarded to the primary device thread, we need to update it there
val messageCount = smsMessageDatabase.getMessageCountForThread(threadId)
val messageID = smsMessageDatabase.getIDForMessageAtIndex(threadId, messageCount - 1) // The message that was just received
if (messageID < 0) { return@successUi }
val messageDatabase = DatabaseFactory.getLokiMessageDatabase(context)
// We need to go through and set all messages which are REQUEST_PENDING to NONE
smsMessageDatabase.getAllMessageIDs(threadId)
.filter { messageDatabase.getFriendRequestStatus(it) == LokiMessageFriendRequestStatus.REQUEST_PENDING }
.forEach {
messageDatabase.setFriendRequestStatus(it, LokiMessageFriendRequestStatus.NONE)
}
// Set the last message to pending
messageDatabase.setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_PENDING)
}
}
}

View File

@@ -12,11 +12,14 @@ import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestS
class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol {
companion object {
private val tableName = "loki_message_friend_request_database"
private val messageFriendRequestTableName = "loki_message_friend_request_database"
private val messageThreadMappingTableName = "loki_message_thread_mapping_database"
private val messageID = "message_id"
private val serverID = "server_id"
private val friendRequestStatus = "friend_request_status"
@JvmStatic val createTableCommand = "CREATE TABLE $tableName ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
private val threadID = "thread_id"
@JvmStatic val createMessageFriendRequestTableCommand = "CREATE TABLE $messageFriendRequestTableName ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
@JvmStatic val createMessageToThreadMappingTableCommand = "CREATE TABLE $messageThreadMappingTableName ($messageID INTEGER PRIMARY KEY, $threadID INTEGER);"
}
override fun getQuoteServerID(quoteID: Long, quoteeHexEncodedPublicKey: String): Long? {
@@ -26,14 +29,14 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
fun getServerID(messageID: Long): Long? {
val database = databaseHelper.readableDatabase
return database.get(tableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor ->
return database.get(messageFriendRequestTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor ->
cursor.getInt(Companion.serverID)
}?.toLong()
}
fun getMessageID(serverID: Long): Long? {
val database = databaseHelper.readableDatabase
return database.get(tableName, "${Companion.serverID} = ?", arrayOf( serverID.toString() )) { cursor ->
return database.get(messageFriendRequestTableName, "${Companion.serverID} = ?", arrayOf( serverID.toString() )) { cursor ->
cursor.getInt(messageID)
}?.toLong()
}
@@ -43,12 +46,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val contentValues = ContentValues(2)
contentValues.put(Companion.messageID, messageID)
contentValues.put(Companion.serverID, serverID)
database.insertOrUpdate(tableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() ))
database.insertOrUpdate(messageFriendRequestTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() ))
}
fun getOriginalThreadID(messageID: Long): Long {
val database = databaseHelper.readableDatabase
return database.get(messageThreadMappingTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor ->
cursor.getInt(Companion.threadID)
}?.toLong() ?: -1L
}
fun setOriginalThreadID(messageID: Long, threadID: Long) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(2)
contentValues.put(Companion.messageID, messageID)
contentValues.put(Companion.threadID, threadID)
database.insertOrUpdate(messageThreadMappingTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() ))
}
fun getFriendRequestStatus(messageID: Long): LokiMessageFriendRequestStatus {
val database = databaseHelper.readableDatabase
val result = database.get(tableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor ->
val result = database.get(messageFriendRequestTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor ->
cursor.getInt(friendRequestStatus)
}
return if (result != null) {
@@ -63,7 +81,7 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val contentValues = ContentValues(2)
contentValues.put(Companion.messageID, messageID)
contentValues.put(Companion.friendRequestStatus, friendRequestStatus.rawValue)
database.insertOrUpdate(tableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() ))
database.insertOrUpdate(messageFriendRequestTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() ))
val threadID = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageID)
notifyConversationListeners(threadID)
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.loki
import android.content.ContentValues
import android.content.Context
import net.sqlcipher.Cursor
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.database.Database
@@ -35,6 +36,11 @@ class LokiPreKeyBundleDatabase(context: Context, helper: SQLCipherOpenHelper) :
"$signedPreKeySignature TEXT," + "$identityKey TEXT NOT NULL," + "$deviceID INTEGER," + "$registrationID INTEGER" + ");"
}
fun resetAllPreKeyBundleInfo() {
TextSecurePreferences.removeLocalRegistrationId(context)
TextSecurePreferences.setSignedPreKeyRegistered(context, false)
}
fun generatePreKeyBundle(hexEncodedPublicKey: String): PreKeyBundle? {
var registrationID = TextSecurePreferences.getLocalRegistrationId(context)
if (registrationID == 0) {
@@ -92,7 +98,14 @@ class LokiPreKeyBundleDatabase(context: Context, helper: SQLCipherOpenHelper) :
fun hasPreKeyBundle(hexEncodedPublicKey: String): Boolean {
val database = databaseHelper.readableDatabase
val cursor = database.query(tableName, null, "${Companion.hexEncodedPublicKey} = ?", arrayOf( hexEncodedPublicKey ), null, null, null)
return cursor != null && cursor.count > 0
var cursor: Cursor? = null
return try {
cursor = database.query(tableName, null, "${Companion.hexEncodedPublicKey} = ?", arrayOf( hexEncodedPublicKey ), null, null, null)
cursor != null && cursor.count > 0
} catch (e: Exception) {
false
} finally {
cursor?.close()
}
}
}

View File

@@ -4,27 +4,23 @@ import android.content.Context
import android.os.Handler
import android.util.Log
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.jobs.PushDecryptJob
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.GroupUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.messages.SignalServiceGroup
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.loki.api.LokiPublicChat
import org.whispersystems.signalservice.loki.api.LokiPublicChatAPI
import org.whispersystems.signalservice.loki.api.LokiPublicChatMessage
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import org.whispersystems.signalservice.loki.utilities.get
import org.whispersystems.signalservice.loki.utilities.successBackground
import java.util.*
class LokiPublicChatPoller(private val context: Context, private val group: LokiPublicChat) {
private val handler = Handler()
@@ -94,18 +90,17 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki
// endregion
// region Polling
private fun pollForNewMessages() {
fun processIncomingMessage(message: LokiPublicChatMessage) {
val id = group.id.toByteArray()
val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null)
val quote = if (message.quote != null) {
SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, listOf())
} else {
null
}
val attachments = message.attachments.mapNotNull { attachment ->
if (attachment.kind != LokiPublicChatMessage.Attachment.Kind.Attachment) { return@mapNotNull null }
SignalServiceAttachmentPointer(
private fun getDataMessage(message: LokiPublicChatMessage): SignalServiceDataMessage {
val id = group.id.toByteArray()
val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null)
val quote = if (message.quote != null) {
SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, listOf())
} else {
null
}
val attachments = message.attachments.mapNotNull { attachment ->
if (attachment.kind != LokiPublicChatMessage.Attachment.Kind.Attachment) { return@mapNotNull null }
SignalServiceAttachmentPointer(
attachment.serverID,
attachment.contentType,
ByteArray(0),
@@ -117,30 +112,35 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki
false,
Optional.fromNullable(attachment.caption),
attachment.url)
}
val linkPreview = message.attachments.firstOrNull { it.kind == LokiPublicChatMessage.Attachment.Kind.LinkPreview }
val signalLinkPreviews = mutableListOf<SignalServiceDataMessage.Preview>()
if (linkPreview != null) {
val attachment = SignalServiceAttachmentPointer(
linkPreview.serverID,
linkPreview.contentType,
ByteArray(0),
Optional.of(linkPreview.size),
Optional.absent(),
linkPreview.width, linkPreview.height,
Optional.absent(),
Optional.of(linkPreview.fileName),
false,
Optional.fromNullable(linkPreview.caption),
linkPreview.url)
signalLinkPreviews.add(SignalServiceDataMessage.Preview(linkPreview.linkPreviewURL!!, linkPreview.linkPreviewTitle!!, Optional.of(attachment)))
}
val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body
val serviceDataMessage = SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null)
}
val linkPreview = message.attachments.firstOrNull { it.kind == LokiPublicChatMessage.Attachment.Kind.LinkPreview }
val signalLinkPreviews = mutableListOf<SignalServiceDataMessage.Preview>()
if (linkPreview != null) {
val attachment = SignalServiceAttachmentPointer(
linkPreview.serverID,
linkPreview.contentType,
ByteArray(0),
Optional.of(linkPreview.size),
Optional.absent(),
linkPreview.width, linkPreview.height,
Optional.absent(),
Optional.of(linkPreview.fileName),
false,
Optional.fromNullable(linkPreview.caption),
linkPreview.url)
signalLinkPreviews.add(SignalServiceDataMessage.Preview(linkPreview.linkPreviewURL!!, linkPreview.linkPreviewTitle!!, Optional.of(attachment)))
}
val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body
return SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null)
}
private fun pollForNewMessages() {
fun processIncomingMessage(message: LokiPublicChatMessage) {
val serviceDataMessage = getDataMessage(message)
val serviceContent = SignalServiceContent(serviceDataMessage, message.hexEncodedPublicKey, SignalServiceAddress.DEFAULT_DEVICE_ID, message.timestamp, false)
val senderDisplayName = "${message.displayName} (...${message.hexEncodedPublicKey.takeLast(8)})"
DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(group.id, message.hexEncodedPublicKey, senderDisplayName)
if (quote != null || attachments.count() > 0 || linkPreview != null) {
if (serviceDataMessage.quote.isPresent || (serviceDataMessage.attachments.isPresent && serviceDataMessage.attachments.get().size > 0) || serviceDataMessage.previews.isPresent) {
PushDecryptJob(context).handleMediaMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID))
} else {
PushDecryptJob(context).handleTextMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID))
@@ -148,61 +148,29 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki
}
fun processOutgoingMessage(message: LokiPublicChatMessage) {
val messageServerID = message.serverID ?: return
val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context)
val isDuplicate = lokiMessageDatabase.getMessageID(messageServerID) != null
val isDuplicate = DatabaseFactory.getLokiMessageDatabase(context).getMessageID(messageServerID) != null
if (isDuplicate) { return }
if (message.body.isEmpty() && message.attachments.isEmpty() && message.quote == null) { return }
val id = group.id.toByteArray()
val mmsDatabase = DatabaseFactory.getMmsDatabase(context)
val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(id, false)), false)
val quote: QuoteModel?
if (message.quote != null) {
quote = QuoteModel(message.quote!!.quotedMessageTimestamp, Address.fromSerialized(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, false, listOf())
val localNumber = TextSecurePreferences.getLocalNumber(context)
val dataMessage = getDataMessage(message)
val transcript = SentTranscriptMessage(localNumber, dataMessage.timestamp, dataMessage, dataMessage.expiresInSeconds.toLong(), Collections.singletonMap(localNumber, false))
transcript.messageServerID = messageServerID
if (dataMessage.quote.isPresent || (dataMessage.attachments.isPresent && dataMessage.attachments.get().size > 0) || dataMessage.previews.isPresent) {
PushDecryptJob(context).handleSynchronizeSentMediaMessage(transcript)
} else {
quote = null
}
// TODO: Handle attachments correctly for our previous messages
val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body
val signalMessage = OutgoingMediaMessage(recipient, body, listOf(), message.timestamp, 0, 0,
ThreadDatabase.DistributionTypes.DEFAULT, quote, listOf(), listOf(), listOf(), listOf())
val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient)
fun finalize() {
val messageID = mmsDatabase.insertMessageOutbox(signalMessage, threadID, false, null)
mmsDatabase.markAsSent(messageID, true)
mmsDatabase.markUnidentified(messageID, false)
lokiMessageDatabase.setServerID(messageID, messageServerID)
}
val urls = LinkPreviewUtil.findWhitelistedUrls(message.body)
val urlCount = urls.size
if (urlCount != 0) {
val lpr = LinkPreviewRepository(context)
var count = 0
urls.forEach { url ->
lpr.getLinkPreview(context, url.url) { lp ->
Util.runOnMain {
count += 1
if (lp.isPresent) { signalMessage.linkPreviews.add(lp.get()) }
if (count == urlCount) {
try {
finalize()
} catch (e: Exception) {
// TODO: Handle
}
}
}
}
}
} else {
finalize()
PushDecryptJob(context).handleSynchronizeSentTextMessage(transcript)
}
}
api.getMessages(group.channel, group.server).success { messages ->
messages.forEach { message ->
if (message.hexEncodedPublicKey != userHexEncodedPublicKey) {
processIncomingMessage(message)
} else {
processOutgoingMessage(message)
api.getMessages(group.channel, group.server).successBackground { messages ->
if (messages.isNotEmpty()) {
val ourDevices = LokiStorageAPI.shared.getAllDevicePublicKeys(userHexEncodedPublicKey).get(setOf())
// Process messages in the background
messages.forEach { message ->
if (ourDevices.contains(message.hexEncodedPublicKey)) {
processOutgoingMessage(message)
} else {
processIncomingMessage(message)
}
}
}
}.fail {

View File

@@ -41,6 +41,8 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
}
fun getFriendRequestStatus(threadID: Long): LokiThreadFriendRequestStatus {
if (threadID < 0) { return LokiThreadFriendRequestStatus.NONE }
val database = databaseHelper.readableDatabase
val result = database.get(friendRequestTableName, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) { cursor ->
cursor.getInt(friendRequestStatus)
@@ -53,6 +55,8 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
}
override fun setFriendRequestStatus(threadID: Long, friendRequestStatus: LokiThreadFriendRequestStatus) {
if (threadID < 0) { return }
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(2)
contentValues.put(Companion.threadID, threadID)

View File

@@ -1,13 +1,19 @@
@file:JvmName("MultiDeviceUtilities")
package org.thoughtcrime.securesms.loki
import android.content.Context
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.toFailVoid
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.logging.Log
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
@@ -16,54 +22,76 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus
import org.whispersystems.signalservice.loki.utilities.recover
import org.whispersystems.signalservice.loki.utilities.retryIfNeeded
import java.util.*
import kotlin.concurrent.schedule
fun getAllDevicePublicKeys(context: Context, hexEncodedPublicKey: String, storageAPI: LokiStorageAPI, block: (devicePublicKey: String, isFriend: Boolean, friendCount: Int) -> Unit) {
fun getAllDeviceFriendRequestStatuses(context: Context, hexEncodedPublicKey: String): Promise<Map<String, LokiThreadFriendRequestStatus>, Exception> {
val lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context)
return LokiStorageAPI.shared.getAllDevicePublicKeys(hexEncodedPublicKey).map { keys ->
val map = mutableMapOf<String, LokiThreadFriendRequestStatus>()
for (devicePublicKey in keys) {
val device = Recipient.from(context, Address.fromSerialized(devicePublicKey), false)
val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(device)
val friendRequestStatus = if (threadID < 0) LokiThreadFriendRequestStatus.NONE else lokiThreadDatabase.getFriendRequestStatus(threadID)
map[devicePublicKey] = friendRequestStatus
}
map
}.recover { mutableMapOf() }
}
fun getAllDevicePublicKeysWithFriendStatus(context: Context, hexEncodedPublicKey: String): Promise<Map<String, Boolean>, Unit> {
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
storageAPI.getAllDevicePublicKeys(hexEncodedPublicKey).success { items ->
val devices = items.toMutableSet()
return LokiStorageAPI.shared.getAllDevicePublicKeys(hexEncodedPublicKey).map { keys ->
val devices = keys.toMutableSet()
if (hexEncodedPublicKey != userHexEncodedPublicKey) {
devices.remove(userHexEncodedPublicKey)
}
val friends = getFriendPublicKeys(context, devices)
val friendMap = mutableMapOf<String, Boolean>()
for (device in devices) {
block(device, friends.contains(device), friends.count())
friendMap[device] = friends.contains(device)
}
}
friendMap
}.toFailVoid()
}
fun shouldAutomaticallyBecomeFriendsWithDevice(publicKey: String, context: Context): Promise<Boolean, Unit> {
val lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context)
val storageAPI = LokiStorageAPI.shared
val deferred = deferred<Boolean, Unit>()
storageAPI.getPrimaryDevicePublicKey(publicKey).success { primaryDevicePublicKey ->
if (primaryDevicePublicKey == null) {
deferred.resolve(false)
return@success
}
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context)
if (primaryDevicePublicKey == userHexEncodedPublicKey) {
storageAPI.getSecondaryDevicePublicKeys(userHexEncodedPublicKey).success { secondaryDevices ->
deferred.resolve(secondaryDevices.contains(publicKey))
}.fail {
deferred.resolve(false)
}
return@success
}
val primaryDevice = Recipient.from(context, Address.fromSerialized(primaryDevicePublicKey), false)
val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(primaryDevice)
if (threadID < 0) {
deferred.resolve(false)
return@success
}
deferred.resolve(lokiThreadDatabase.getFriendRequestStatus(threadID) == LokiThreadFriendRequestStatus.FRIENDS)
fun getFriendCount(context: Context, devices: Set<String>): Int {
return getFriendPublicKeys(context, devices).count()
}
fun shouldAutomaticallyBecomeFriendsWithDevice(publicKey: String, context: Context): Promise<Boolean, Exception> {
// Don't become friends if we're a group
if (!Address.fromSerialized(publicKey).isPhone) {
return Promise.of(false)
}
// If this public key is our primary device then we should become friends
if (publicKey == TextSecurePreferences.getMasterHexEncodedPublicKey(context)) {
return Promise.of(true)
}
return LokiStorageAPI.shared.getPrimaryDevicePublicKey(publicKey).bind { primaryDevicePublicKey ->
// If the public key doesn't have any other devices then go through regular friend request logic
if (primaryDevicePublicKey == null) {
return@bind Promise.of(false)
}
// If the primary device public key matches our primary device then we should become friends since this is our other device
if (primaryDevicePublicKey == TextSecurePreferences.getMasterHexEncodedPublicKey(context)) {
return@bind Promise.of(true)
}
// If we are friends with any of the other devices then we should become friends
isFriendsWithAnyLinkedDevice(context, Address.fromSerialized(primaryDevicePublicKey))
}
return deferred.promise
}
fun sendPairingAuthorisationMessage(context: Context, contactHexEncodedPublicKey: String, authorisation: PairingAuthorisation): Promise<Unit, Exception> {
val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender()
val address = SignalServiceAddress(contactHexEncodedPublicKey)
val message = SignalServiceDataMessage.newBuilder().withBody("").withPairingAuthorisation(authorisation)
val message = SignalServiceDataMessage.newBuilder().withBody(null).withPairingAuthorisation(authorisation)
// A REQUEST should always act as a friend request. A GRANT should always be replying back as a normal message.
if (authorisation.type == PairingAuthorisation.Type.REQUEST) {
val preKeyBundle = DatabaseFactory.getLokiPreKeyBundleDatabase(context).generatePreKeyBundle(address.number)
@@ -84,4 +112,77 @@ fun sendPairingAuthorisationMessage(context: Context, contactHexEncodedPublicKey
Log.d("Loki", "Failed to send authorisation message to: $contactHexEncodedPublicKey.")
Promise.ofFail(e)
}
}
}
fun signAndSendPairingAuthorisationMessage(context: Context, pairingAuthorisation: PairingAuthorisation) {
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize()
val signedPairingAuthorisation = pairingAuthorisation.sign(PairingAuthorisation.Type.GRANT, userPrivateKey)
if (signedPairingAuthorisation == null || signedPairingAuthorisation.type != PairingAuthorisation.Type.GRANT) {
Log.d("Loki", "Failed to sign pairing authorization.")
return
}
DatabaseFactory.getLokiAPIDatabase(context).insertOrUpdatePairingAuthorisation(signedPairingAuthorisation)
TextSecurePreferences.setMultiDevice(context, true)
val address = Address.fromSerialized(pairingAuthorisation.secondaryDevicePublicKey);
val sendPromise = retryIfNeeded(8) {
sendPairingAuthorisationMessage(context, address.serialize(), signedPairingAuthorisation)
}.fail {
Log.d("Loki", "Failed to send pairing authorization message to ${address.serialize()}.")
}
val updatePromise = LokiStorageAPI.shared.updateUserDeviceMappings().fail {
Log.d("Loki", "Failed to update device mapping")
}
// If both promises complete successfully then we should sync our contacts
all(listOf(sendPromise, updatePromise), cancelOthersOnError = false).success {
Log.d("Loki", "Successfully pairing with a secondary device! Syncing contacts.")
// Send out sync contact after a delay
Timer().schedule(3000) {
MessageSender.syncAllContacts(context, address)
}
}
}
fun isOneOfOurDevices(context: Context, address: Address): Promise<Boolean, Exception> {
if (address.isGroup || address.isEmail || address.isMmsGroup) {
return Promise.of(false)
}
val ourPublicKey = TextSecurePreferences.getLocalNumber(context)
return LokiStorageAPI.shared.getAllDevicePublicKeys(ourPublicKey).map { devices ->
devices.contains(address.serialize())
}
}
fun isFriendsWithAnyLinkedDevice(context: Context, recipient: Recipient): Promise<Boolean, Exception> {
return isFriendsWithAnyLinkedDevice(context, recipient.address)
}
fun isFriendsWithAnyLinkedDevice(context: Context, address: Address): Promise<Boolean, Exception> {
if (!address.isPhone) { return Promise.of(true) }
return getAllDeviceFriendRequestStatuses(context, address.serialize()).map { map ->
for (status in map.values) {
if (status == LokiThreadFriendRequestStatus.FRIENDS) {
return@map true
}
}
false
}
}
fun hasPendingFriendRequestWithAnyLinkedDevice(context: Context, recipient: Recipient): Promise<Boolean, Exception> {
if (recipient.isGroupRecipient) { return Promise.of(false) }
return getAllDeviceFriendRequestStatuses(context, recipient.address.serialize()).map { map ->
for (status in map.values) {
if (status == LokiThreadFriendRequestStatus.REQUEST_SENDING || status == LokiThreadFriendRequestStatus.REQUEST_SENT || status == LokiThreadFriendRequestStatus.REQUEST_RECEIVED) {
return@map true
}
}
false
}
}

View File

@@ -68,8 +68,9 @@ class NewConversationActivity : PassphraseRequiredActionBarActivity(), ScanListe
fun startNewConversationIfPossible(hexEncodedPublicKey: String) {
if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.fragment_new_conversation_invalid_public_key_message, Toast.LENGTH_SHORT).show() }
val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this)
if (hexEncodedPublicKey == userHexEncodedPublicKey) { return Toast.makeText(this, R.string.fragment_new_conversation_note_to_self_not_supported_message, Toast.LENGTH_SHORT).show() }
val contact = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), true)
// If we try to contact our master device then redirect to note to self
val contactPublicKey = if (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == hexEncodedPublicKey) userHexEncodedPublicKey else hexEncodedPublicKey
val contact = Recipient.from(this, Address.fromSerialized(contactPublicKey), true)
val intent = Intent(this, ConversationActivity::class.java)
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, contact.address)
intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA))

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.loki
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.jobmanager.Data
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.BaseJob
import org.thoughtcrime.securesms.logging.Log
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import java.util.concurrent.TimeUnit
class PushBackgroundMessageSendJob private constructor(
parameters: Parameters,
private val recipient: String,
private val messageBody: String?,
private val friendRequest: Boolean
) : BaseJob(parameters) {
companion object {
const val KEY = "PushBackgroundMessageSendJob"
private val TAG = PushBackgroundMessageSendJob::class.java.simpleName
private val KEY_RECIPIENT = "recipient"
private val KEY_MESSAGE_BODY = "message_body"
private val KEY_FRIEND_REQUEST = "asFriendRequest"
}
constructor(recipient: String): this(recipient, null, false)
constructor(recipient: String, messageBody: String?, friendRequest: Boolean) : this(Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(1)
.build(),
recipient, messageBody, friendRequest)
override fun serialize(): Data {
return Data.Builder()
.putString(KEY_RECIPIENT, recipient)
.putString(KEY_MESSAGE_BODY, messageBody)
.putBoolean(KEY_FRIEND_REQUEST, friendRequest)
.build()
}
override fun getFactoryKey(): String {
return KEY
}
public override fun onRun() {
val message = SignalServiceDataMessage.newBuilder()
.withTimestamp(System.currentTimeMillis())
.withBody(messageBody)
if (friendRequest) {
val bundle = DatabaseFactory.getLokiPreKeyBundleDatabase(context).generatePreKeyBundle(recipient)
message.withPreKeyBundle(bundle)
.asFriendRequest(true)
}
val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender()
val address = SignalServiceAddress(recipient)
try {
messageSender.sendMessage(-1, address, Optional.absent<UnidentifiedAccessPair>(), message.build()) // The message ID doesn't matter
} catch (e: Exception) {
Log.d("Loki", "Failed to send background message to: $recipient.")
throw e
}
}
public override fun onShouldRetry(e: Exception): Boolean {
// Loki - Disable since we have our own retrying when sending messages
return false
}
override fun onCanceled() {}
class Factory : Job.Factory<PushBackgroundMessageSendJob> {
override fun create(parameters: Parameters, data: Data): PushBackgroundMessageSendJob {
try {
val recipient = data.getString(KEY_RECIPIENT)
val messageBody = if (data.hasString(KEY_MESSAGE_BODY)) data.getString(KEY_MESSAGE_BODY) else null
val friendRequest = data.getBooleanOrDefault(KEY_FRIEND_REQUEST, false)
return PushBackgroundMessageSendJob(parameters, recipient, messageBody, friendRequest)
} catch (e: IOException) {
throw AssertionError(e)
}
}
}
}

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.loki
import org.thoughtcrime.securesms.database.Address
import org.thoughtcrime.securesms.dependencies.InjectableType
import org.thoughtcrime.securesms.jobmanager.Data
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.BaseJob
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class PushMessageSyncSendJob private constructor(
parameters: Parameters,
private val messageID: Long,
private val recipient: Address,
private val timestamp: Long,
private val message: ByteArray,
private val ttl: Int
) : BaseJob(parameters), InjectableType {
companion object {
const val KEY = "PushMessageSyncSendJob"
private val TAG = PushMessageSyncSendJob::class.java.simpleName
private val KEY_MESSAGE_ID = "message_id"
private val KEY_RECIPIENT = "recipient"
private val KEY_TIMESTAMP = "timestamp"
private val KEY_MESSAGE = "message"
private val KEY_TTL = "ttl"
}
@Inject
lateinit var messageSender: SignalServiceMessageSender
constructor(messageID: Long, recipient: Address, timestamp: Long, message: ByteArray, ttl: Int) : this(Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(1)
.build(),
messageID, recipient, timestamp, message, ttl)
override fun serialize(): Data {
return Data.Builder()
.putLong(KEY_MESSAGE_ID, messageID)
.putString(KEY_RECIPIENT, recipient.serialize())
.putLong(KEY_TIMESTAMP, timestamp)
.putByteArray(KEY_MESSAGE, message)
.putInt(KEY_TTL, ttl)
.build()
}
override fun getFactoryKey(): String {
return KEY
}
@Throws(IOException::class, UntrustedIdentityException::class)
public override fun onRun() {
// Don't send sync messages to a group
if (recipient.isGroup || recipient.isEmail) { return }
messageSender.lokiSendSyncMessage(messageID, SignalServiceAddress(recipient.toPhoneString()), timestamp, message, ttl)
}
public override fun onShouldRetry(e: Exception): Boolean {
// Loki - Disable since we have our own retrying when sending messages
return false
}
override fun onCanceled() {}
class Factory : Job.Factory<PushMessageSyncSendJob> {
override fun create(parameters: Parameters, data: Data): PushMessageSyncSendJob {
try {
return PushMessageSyncSendJob(parameters,
data.getLong(KEY_MESSAGE_ID),
Address.fromSerialized(data.getString(KEY_RECIPIENT)),
data.getLong(KEY_TIMESTAMP),
data.getByteArray(KEY_MESSAGE),
data.getInt(KEY_TTL))
} catch (e: IOException) {
throw AssertionError(e)
}
}
}
}

View File

@@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.AsyncTask
import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
@@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.curve25519.Curve25519
import org.whispersystems.libsignal.util.KeyHelper
import org.whispersystems.signalservice.loki.api.LokiStorageAPI
import org.whispersystems.signalservice.loki.api.PairingAuthorisation
import org.whispersystems.signalservice.loki.crypto.MnemonicCodec
import org.whispersystems.signalservice.loki.utilities.Analytics
@@ -55,8 +55,7 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate {
copyButton.setOnClickListener { copy() }
toggleRegisterModeButton.setOnClickListener { mode = Mode.Register }
toggleRestoreModeButton.setOnClickListener { mode = Mode.Restore }
// TODO: Enable this again later
// toggleLinkModeButton.setOnClickListener { mode = Mode.Link }
toggleLinkModeButton.setOnClickListener { mode = Mode.Link }
mainButton.setOnClickListener { handleMainButtonTapped() }
Analytics.shared.track("Seed Screen Viewed")
}
@@ -205,8 +204,10 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate {
application.setUpP2PAPI()
application.setUpStorageAPIIfNeeded()
DeviceLinkingDialog.show(this, DeviceLinkingView.Mode.Slave, this)
retryIfNeeded(8) {
sendPairingAuthorisationMessage(this@SeedActivity, authorisation.primaryDevicePublicKey, authorisation).get()
AsyncTask.execute {
retryIfNeeded(8) {
sendPairingAuthorisationMessage(this@SeedActivity, authorisation.primaryDevicePublicKey, authorisation)
}
}
} else {
startActivity(Intent(this, DisplayNameActivity::class.java))
@@ -227,25 +228,9 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate {
resetForRegistration()
}
override fun sendPairingAuthorizedMessage(pairingAuthorisation: PairingAuthorisation) {
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).privateKey.serialize()
val signedPairingAuthorisation = pairingAuthorisation.sign(PairingAuthorisation.Type.GRANT, userPrivateKey)
if (signedPairingAuthorisation == null || signedPairingAuthorisation.type != PairingAuthorisation.Type.GRANT) {
Log.d("Loki", "Failed to sign pairing authorization.")
return
}
retryIfNeeded(8) {
sendPairingAuthorisationMessage(this, pairingAuthorisation.secondaryDevicePublicKey, signedPairingAuthorisation).get()
}.fail {
Log.d("Loki", "Failed to send pairing authorization message to ${pairingAuthorisation.secondaryDevicePublicKey}.")
}
DatabaseFactory.getLokiAPIDatabase(this).insertOrUpdatePairingAuthorisation(signedPairingAuthorisation)
LokiStorageAPI.shared.updateUserDeviceMappings()
}
private fun resetForRegistration() {
IdentityKeyUtil.delete(this, IdentityKeyUtil.lokiSeedKey)
TextSecurePreferences.removeLocalRegistrationId(this)
DatabaseFactory.getLokiPreKeyBundleDatabase(this).resetAllPreKeyBundleInfo()
TextSecurePreferences.removeLocalNumber(this)
TextSecurePreferences.setHasSeenWelcomeScreen(this, false)
TextSecurePreferences.setPromptedPushRegistration(this, false)