mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-11 20:47:42 +00:00
Merge remote-tracking branch 'upstream/dev' into disappearing-messages
# Conflicts: # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt # app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt # app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java # app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java # app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt # app/src/main/res/layout/activity_conversation_v2_action_bar.xml # app/src/main/res/layout/expiration_dialog.xml # app/src/main/res/menu/menu_conversation_expiration.xml # app/src/main/res/menu/menu_conversation_expiration_on.xml # app/src/main/res/values/strings.xml # libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt # libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt # libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java # libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java # libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt # libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt # libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt # libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt # libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java
This commit is contained in:
4
libsession/src/debug/res/values/values.xml
Normal file
4
libsession/src/debug/res/values/values.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="screen_security_default">false</bool>
|
||||
</resources>
|
@@ -20,8 +20,10 @@ interface MessageDataProvider {
|
||||
* @return pair of sms or mms table-specific ID and whether it is in SMS table
|
||||
*/
|
||||
fun getMessageID(serverId: Long, threadId: Long): Pair<Long, Boolean>?
|
||||
fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>>
|
||||
fun deleteMessage(messageID: Long, isSms: Boolean)
|
||||
fun updateMessageAsDeleted(timestamp: Long, author: String)
|
||||
fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean)
|
||||
fun updateMessageAsDeleted(timestamp: Long, author: String): Long?
|
||||
fun getServerHashForMessage(messageID: Long): String?
|
||||
fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment?
|
||||
fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream?
|
||||
@@ -36,7 +38,7 @@ interface MessageDataProvider {
|
||||
fun isOutgoingMessage(timestamp: Long): Boolean
|
||||
fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult)
|
||||
fun handleFailedAttachmentUpload(attachmentId: Long)
|
||||
fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>?
|
||||
fun getMessageForQuote(timestamp: Long, author: Address): Triple<Long, Boolean, String>?
|
||||
fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List<Attachment>
|
||||
fun getMessageBodyFor(timestamp: Long, author: String): String
|
||||
fun getAttachmentIDsFor(messageID: Long): List<Long>
|
||||
|
@@ -13,10 +13,12 @@ import org.session.libsession.messaging.messages.Message
|
||||
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.Profile
|
||||
import org.session.libsession.messaging.messages.visible.Reaction
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.GroupMember
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
|
||||
@@ -35,10 +37,8 @@ interface StorageProtocol {
|
||||
// General
|
||||
fun getUserPublicKey(): String?
|
||||
fun getUserX25519KeyPair(): ECKeyPair
|
||||
fun getUserDisplayName(): String?
|
||||
fun getUserProfileKey(): ByteArray?
|
||||
fun getUserProfilePictureURL(): String?
|
||||
fun setUserProfilePictureURL(newProfilePicture: String)
|
||||
fun getUserProfile(): Profile
|
||||
fun setProfileAvatar(recipient: Recipient, profileAvatar: String?)
|
||||
// Signal
|
||||
fun getOrGenerateRegistrationID(): Int
|
||||
|
||||
@@ -50,7 +50,7 @@ interface StorageProtocol {
|
||||
fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob?
|
||||
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
|
||||
fun getMessageReceiveJob(messageReceiveJobID: String): Job?
|
||||
fun getGroupAvatarDownloadJob(server: String, room: String): Job?
|
||||
fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): Job?
|
||||
fun resumeMessageSendJobIfNeeded(messageSendJobID: String)
|
||||
fun isJobCanceled(job: Job): Boolean
|
||||
|
||||
@@ -67,7 +67,7 @@ interface StorageProtocol {
|
||||
fun getAllOpenGroups(): Map<Long, OpenGroup>
|
||||
fun updateOpenGroup(openGroup: OpenGroup)
|
||||
fun getOpenGroup(threadId: Long): OpenGroup?
|
||||
fun addOpenGroup(urlAsString: String)
|
||||
fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo?
|
||||
fun onOpenGroupAdded(server: String)
|
||||
fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean
|
||||
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
|
||||
@@ -81,6 +81,8 @@ interface StorageProtocol {
|
||||
// Open Group Metadata
|
||||
fun updateTitle(groupID: String, newValue: String)
|
||||
fun updateProfilePicture(groupID: String, newValue: ByteArray)
|
||||
fun removeProfilePicture(groupID: String)
|
||||
fun hasDownloadedProfilePicture(groupID: String): Boolean
|
||||
fun setUserCount(room: String, server: String, newValue: Int)
|
||||
|
||||
// Last Message Server ID
|
||||
@@ -105,10 +107,13 @@ interface StorageProtocol {
|
||||
fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment>
|
||||
fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name
|
||||
fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long)
|
||||
fun markAsResyncing(timestamp: Long, author: String)
|
||||
fun markAsSyncing(timestamp: Long, author: String)
|
||||
fun markAsSending(timestamp: Long, author: String)
|
||||
fun markAsSent(timestamp: Long, author: String)
|
||||
fun markUnidentified(timestamp: Long, author: String)
|
||||
fun setErrorMessage(timestamp: Long, author: String, error: Exception)
|
||||
fun markAsSyncFailed(timestamp: Long, author: String, error: Exception)
|
||||
fun markAsSentFailed(timestamp: Long, author: String, error: Exception)
|
||||
fun clearErrorMessage(messageID: Long)
|
||||
fun setMessageServerHash(messageID: Long, serverHash: String)
|
||||
|
||||
@@ -155,6 +160,7 @@ interface StorageProtocol {
|
||||
fun trimThread(threadID: Long, threadLimit: Int)
|
||||
fun trimThreadBefore(threadID: Long, timestamp: Long)
|
||||
fun getMessageCount(threadID: Long): Long
|
||||
fun deleteConversation(threadId: Long)
|
||||
|
||||
// Contacts
|
||||
fun getContactWithSessionID(sessionID: String): Contact?
|
||||
@@ -174,7 +180,7 @@ interface StorageProtocol {
|
||||
*/
|
||||
fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>, runIncrement: Boolean, runThreadUpdate: Boolean): Long?
|
||||
fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean)
|
||||
fun incrementUnread(threadId: Long, amount: Int)
|
||||
fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int)
|
||||
fun updateThread(threadId: Long, unarchive: Boolean)
|
||||
fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long)
|
||||
fun insertMessageRequestResponse(response: MessageRequestResponse)
|
||||
@@ -198,7 +204,7 @@ interface StorageProtocol {
|
||||
fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean)
|
||||
fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long)
|
||||
fun deleteReactions(messageId: Long, mms: Boolean)
|
||||
fun unblock(toUnblock: List<Recipient>)
|
||||
fun unblock(toUnblock: Iterable<Recipient>)
|
||||
fun blockedContacts(): List<Recipient>
|
||||
fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration?
|
||||
fun setExpirationConfiguration(config: ExpirationConfiguration)
|
||||
|
@@ -16,15 +16,6 @@ object FileServerApi {
|
||||
private const val serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
|
||||
const val server = "http://filev2.getsession.org"
|
||||
const val maxFileSize = 10_000_000 // 10 MB
|
||||
/**
|
||||
* The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes
|
||||
* is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP
|
||||
* request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also
|
||||
* be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when
|
||||
* uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only
|
||||
* possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds.
|
||||
*/
|
||||
const val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5?
|
||||
|
||||
sealed class Error(message: String) : Exception(message) {
|
||||
object ParsingFailed : Error("Invalid response.")
|
||||
@@ -77,7 +68,11 @@ object FileServerApi {
|
||||
OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map {
|
||||
it.body ?: throw Error.ParsingFailed
|
||||
}.fail { e ->
|
||||
Log.e("Loki", "File server request failed.", e)
|
||||
when (e) {
|
||||
// No need for the stack trace for HTTP errors
|
||||
is HTTP.HTTPRequestFailedException -> Log.e("Loki", "File server request failed due to error: ${e.message}")
|
||||
else -> Log.e("Loki", "File server request failed", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
|
||||
|
@@ -42,7 +42,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val threadID = storage.getThreadIdForMms(databaseMessageID)
|
||||
@@ -59,7 +59,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment")
|
||||
messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID)
|
||||
}
|
||||
this.handlePermanentFailure(exception)
|
||||
this.handlePermanentFailure(dispatcherName, exception)
|
||||
} else if (exception == Error.DuplicateData) {
|
||||
attachment?.let { id ->
|
||||
Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data")
|
||||
@@ -68,7 +68,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data")
|
||||
messageDataProvider.setAttachmentState(AttachmentState.DONE, AttachmentId(attachmentID,0), databaseMessageID)
|
||||
}
|
||||
this.handleSuccess()
|
||||
this.handleSuccess(dispatcherName)
|
||||
} else {
|
||||
if (failureCount + 1 >= maxFailureCount) {
|
||||
attachment?.let { id ->
|
||||
@@ -79,7 +79,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID)
|
||||
}
|
||||
}
|
||||
this.handleFailure(exception)
|
||||
this.handleFailure(dispatcherName, exception)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
Log.d("AttachmentDownloadJob", "deleting tempfile")
|
||||
tempFile.delete()
|
||||
Log.d("AttachmentDownloadJob", "succeeding job")
|
||||
handleSuccess()
|
||||
handleSuccess(dispatcherName)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AttachmentDownloadJob", "Error processing attachment download", e)
|
||||
tempFile?.delete()
|
||||
@@ -169,17 +169,17 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
private fun handleSuccess(dispatcherName: String) {
|
||||
Log.w("AttachmentDownloadJob", "Attachment downloaded successfully.")
|
||||
delegate?.handleJobSucceeded(this)
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
private fun handlePermanentFailure(e: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, e)
|
||||
private fun handlePermanentFailure(dispatcherName: String, e: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, e)
|
||||
}
|
||||
|
||||
private fun handleFailure(e: Exception) {
|
||||
delegate?.handleJobFailed(this, e)
|
||||
private fun handleFailure(dispatcherName: String, e: Exception) {
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
}
|
||||
|
||||
private fun createTempFile(): File {
|
||||
|
@@ -45,29 +45,29 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
|
||||
private val MESSAGE_SEND_JOB_ID_KEY = "message_send_job_id"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
try {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID)
|
||||
?: return handleFailure(Error.NoAttachment)
|
||||
?: return handleFailure(dispatcherName, Error.NoAttachment)
|
||||
val openGroup = storage.getOpenGroup(threadID.toLong())
|
||||
if (openGroup != null) {
|
||||
val keyAndResult = upload(attachment, openGroup.server, false) {
|
||||
OpenGroupApi.upload(it, openGroup.room, openGroup.server)
|
||||
}
|
||||
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
|
||||
handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second)
|
||||
} else {
|
||||
val keyAndResult = upload(attachment, FileServerApi.server, true) {
|
||||
FileServerApi.upload(it)
|
||||
}
|
||||
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
|
||||
handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e == Error.NoAttachment) {
|
||||
this.handlePermanentFailure(e)
|
||||
this.handlePermanentFailure(dispatcherName, e)
|
||||
} else {
|
||||
this.handleFailure(e)
|
||||
this.handleFailure(dispatcherName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,9 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
|
||||
return Pair(key, UploadResult(id, "${server}/file/$id", digest))
|
||||
}
|
||||
|
||||
private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) {
|
||||
private fun handleSuccess(dispatcherName: String, attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) {
|
||||
Log.d(TAG, "Attachment uploaded successfully.")
|
||||
delegate?.handleJobSucceeded(this)
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
messageDataProvider.handleSuccessfulAttachmentUpload(attachmentID, attachment, attachmentKey, uploadResult)
|
||||
if (attachment.contentType.startsWith("audio/")) {
|
||||
@@ -144,16 +144,16 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
|
||||
storage.resumeMessageSendJobIfNeeded(messageSendJobID)
|
||||
}
|
||||
|
||||
private fun handlePermanentFailure(e: Exception) {
|
||||
private fun handlePermanentFailure(dispatcherName: String, e: Exception) {
|
||||
Log.w(TAG, "Attachment upload failed permanently due to error: $this.")
|
||||
delegate?.handleJobFailedPermanently(this, e)
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, e)
|
||||
MessagingModuleConfiguration.shared.messageDataProvider.handleFailedAttachmentUpload(attachmentID)
|
||||
failAssociatedMessageSendJob(e)
|
||||
}
|
||||
|
||||
private fun handleFailure(e: Exception) {
|
||||
private fun handleFailure(dispatcherName: String, e: Exception) {
|
||||
Log.w(TAG, "Attachment upload failed due to error: $this.")
|
||||
delegate?.handleJobFailed(this, e)
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
if (failureCount + 1 >= maxFailureCount) {
|
||||
failAssociatedMessageSendJob(e)
|
||||
}
|
||||
|
@@ -29,37 +29,26 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
|
||||
return "$server.$room"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
try {
|
||||
val openGroup = OpenGroupUrlParser.parseUrl(joinUrl)
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val allOpenGroups = storage.getAllOpenGroups().map { it.value.joinURL }
|
||||
if (allOpenGroups.contains(openGroup.joinUrl())) {
|
||||
Log.e("OpenGroupDispatcher", "Failed to add group because", DuplicateGroupException())
|
||||
delegate?.handleJobFailed(this, DuplicateGroupException())
|
||||
delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException())
|
||||
return
|
||||
}
|
||||
// get image
|
||||
storage.setOpenGroupPublicKey(openGroup.server, openGroup.serverPublicKey)
|
||||
val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(openGroup.room, openGroup.server, false).get()
|
||||
storage.setServerCapabilities(openGroup.server, capabilities.capabilities)
|
||||
val imageId = info.imageId
|
||||
storage.addOpenGroup(openGroup.joinUrl())
|
||||
if (imageId != null) {
|
||||
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(openGroup.server, openGroup.room, imageId).get()
|
||||
val groupId = GroupUtil.getEncodedOpenGroupID("${openGroup.server}.${openGroup.room}".toByteArray())
|
||||
storage.updateProfilePicture(groupId, bytes)
|
||||
storage.updateTimestampUpdated(groupId, System.currentTimeMillis())
|
||||
}
|
||||
Log.d(KEY, "onOpenGroupAdded(${openGroup.server})")
|
||||
storage.onOpenGroupAdded(openGroup.server)
|
||||
} catch (e: Exception) {
|
||||
Log.e("OpenGroupDispatcher", "Failed to add group because",e)
|
||||
delegate?.handleJobFailed(this, e)
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
return
|
||||
}
|
||||
Log.d("Loki", "Group added successfully")
|
||||
delegate?.handleJobSucceeded(this)
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
override fun serialize(): Data = Data.Builder()
|
||||
|
@@ -11,14 +11,11 @@ import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||
import org.session.libsession.messaging.messages.control.UnsendRequest
|
||||
import org.session.libsession.messaging.messages.visible.ParsedMessage
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.MessageReceiver
|
||||
import org.session.libsession.messaging.sending_receiving.handle
|
||||
import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions
|
||||
import org.session.libsession.messaging.sending_receiving.handleVisibleMessage
|
||||
import org.session.libsession.messaging.sending_receiving.updateExpiryIfNeeded
|
||||
import org.session.libsession.messaging.sending_receiving.*
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
@@ -69,11 +66,11 @@ class BatchMessageReceiveJob(
|
||||
return storage.getOrCreateThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID)
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
executeAsync().get()
|
||||
override fun execute(dispatcherName: String) {
|
||||
executeAsync(dispatcherName).get()
|
||||
}
|
||||
|
||||
fun executeAsync(): Promise<Unit, Exception> {
|
||||
fun executeAsync(dispatcherName: String): Promise<Unit, Exception> {
|
||||
return task {
|
||||
val threadMap = mutableMapOf<Long, MutableList<ParsedMessage>>()
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
@@ -95,12 +92,23 @@ class BatchMessageReceiveJob(
|
||||
threadMap[threadID]!! += parsedParams
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Couldn't receive message.", e)
|
||||
if (e is MessageReceiver.Error && !e.isRetryable) {
|
||||
Log.e(TAG, "Message failed permanently",e)
|
||||
} else {
|
||||
Log.e(TAG, "Message failed",e)
|
||||
failures += messageParameters
|
||||
when (e) {
|
||||
is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> {
|
||||
Log.i(TAG, "Couldn't receive message, failed with error: ${e.message} (id: $id)")
|
||||
}
|
||||
is MessageReceiver.Error -> {
|
||||
if (!e.isRetryable) {
|
||||
Log.e(TAG, "Couldn't receive message, failed permanently (id: $id)", e)
|
||||
}
|
||||
else {
|
||||
Log.e(TAG, "Couldn't receive message, failed (id: $id)", e)
|
||||
failures += messageParameters
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Couldn't receive message, failed (id: $id)", e)
|
||||
failures += messageParameters
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,46 +117,68 @@ class BatchMessageReceiveJob(
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val deferredThreadMap = threadMap.entries.map { (threadId, messages) ->
|
||||
async {
|
||||
val messageIds = mutableListOf<Pair<Long, Boolean>>()
|
||||
// The LinkedHashMap should preserve insertion order
|
||||
val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>()
|
||||
|
||||
messages.forEach { (parameters, message, proto) ->
|
||||
try {
|
||||
if (message is VisibleMessage) {
|
||||
MessageReceiver.updateExpiryIfNeeded(message, proto, openGroupID)
|
||||
when (message) {
|
||||
is VisibleMessage -> {
|
||||
MessageReceiver.updateExpiryIfNeeded(message, proto, openGroupID)
|
||||
val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID,
|
||||
runIncrement = false,
|
||||
runThreadUpdate = false,
|
||||
runProfileUpdate = true
|
||||
)
|
||||
if (messageId != null && message.reaction == null) {
|
||||
val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(
|
||||
IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
|
||||
messageIds += messageId to (message.sender == localUserPublicKey || isUserBlindedSender)
|
||||
runProfileUpdate = true)
|
||||
|
||||
if (messageId != null && message.reaction == null) {
|
||||
val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(
|
||||
IdPrefix.BLINDED, it.publicKey.asBytes).hexString }
|
||||
messageIds[messageId] = Pair(
|
||||
(message.sender == localUserPublicKey || isUserBlindedSender),
|
||||
message.hasMention
|
||||
)
|
||||
}
|
||||
parameters.openGroupMessageServerID?.let {
|
||||
MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions)
|
||||
}
|
||||
}
|
||||
parameters.openGroupMessageServerID?.let {
|
||||
MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions)
|
||||
|
||||
is UnsendRequest -> {
|
||||
val deletedMessageId = MessageReceiver.handleUnsendRequest(message)
|
||||
|
||||
// If we removed a message then ensure it isn't in the 'messageIds'
|
||||
if (deletedMessageId != null) {
|
||||
messageIds.remove(deletedMessageId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageReceiver.handle(message, proto, openGroupID)
|
||||
|
||||
else -> MessageReceiver.handle(message, proto, openGroupID)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Couldn't process message.", e)
|
||||
Log.e(TAG, "Couldn't process message (id: $id)", e)
|
||||
if (e is MessageReceiver.Error && !e.isRetryable) {
|
||||
Log.e(TAG, "Message failed permanently",e)
|
||||
Log.e(TAG, "Message failed permanently (id: $id)", e)
|
||||
} else {
|
||||
Log.e(TAG, "Message failed",e)
|
||||
Log.e(TAG, "Message failed (id: $id)", e)
|
||||
failures += parameters
|
||||
}
|
||||
}
|
||||
}
|
||||
// increment unreads, notify, and update thread
|
||||
val unreadFromMine = messageIds.indexOfLast { (_,fromMe) -> fromMe }
|
||||
var trueUnreadCount = messageIds.filter { (_,fromMe) -> !fromMe }.size
|
||||
val unreadFromMine = messageIds.map { it.value.first }.indexOfLast { it }
|
||||
var trueUnreadCount = messageIds.filter { !it.value.first }.size
|
||||
var trueUnreadMentionCount = messageIds.filter { !it.value.first && it.value.second }.size
|
||||
if (unreadFromMine >= 0) {
|
||||
trueUnreadCount -= (unreadFromMine + 1)
|
||||
storage.markConversationAsRead(threadId, false)
|
||||
|
||||
val trueUnreadIds = messageIds.keys.toList().subList(unreadFromMine + 1, messageIds.keys.count())
|
||||
trueUnreadCount = trueUnreadIds.size
|
||||
trueUnreadMentionCount = messageIds
|
||||
.filter { trueUnreadIds.contains(it.key) && !it.value.first && it.value.second }
|
||||
.size
|
||||
}
|
||||
if (trueUnreadCount > 0) {
|
||||
storage.incrementUnread(threadId, trueUnreadCount)
|
||||
storage.incrementUnread(threadId, trueUnreadCount, trueUnreadMentionCount)
|
||||
}
|
||||
storage.updateThread(threadId, true)
|
||||
SSKEnvironment.shared.notificationManager.updateNotification(context, threadId)
|
||||
@@ -158,19 +188,21 @@ class BatchMessageReceiveJob(
|
||||
deferredThreadMap.awaitAll()
|
||||
}
|
||||
if (failures.isEmpty()) {
|
||||
handleSuccess()
|
||||
handleSuccess(dispatcherName)
|
||||
} else {
|
||||
handleFailure()
|
||||
handleFailure(dispatcherName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
this.delegate?.handleJobSucceeded(this)
|
||||
private fun handleSuccess(dispatcherName: String) {
|
||||
Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)")
|
||||
this.delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
private fun handleFailure() {
|
||||
this.delegate?.handleJobFailed(this, Exception("One or more jobs resulted in failure"))
|
||||
private fun handleFailure(dispatcherName: String) {
|
||||
Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)")
|
||||
this.delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure"))
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
|
@@ -3,27 +3,47 @@ package org.session.libsession.messaging.jobs
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
|
||||
class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
|
||||
class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: String?) : Job {
|
||||
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
override val maxFailureCount: Int = 10
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
if (imageId == null) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob now requires imageId"))
|
||||
return
|
||||
}
|
||||
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val storedImageId = storage.getOpenGroup(room, server)?.imageId
|
||||
|
||||
if (storedImageId == null || storedImageId != imageId) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId does not match the OpenGroup"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val info = OpenGroupApi.getRoomInfo(room, server).get()
|
||||
val imageId = info.imageId ?: return
|
||||
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, info.token, imageId).get()
|
||||
val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, room, imageId).get()
|
||||
|
||||
// Once the download is complete the imageId might no longer match, so we need to fetch it again just in case
|
||||
val postDownloadStoredImageId = storage.getOpenGroup(room, server)?.imageId
|
||||
|
||||
if (postDownloadStoredImageId == null || postDownloadStoredImageId != imageId) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId no longer matches the OpenGroup"))
|
||||
return
|
||||
}
|
||||
|
||||
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
|
||||
storage.updateProfilePicture(groupId, bytes)
|
||||
storage.updateTimestampUpdated(groupId, System.currentTimeMillis())
|
||||
delegate?.handleJobSucceeded(this)
|
||||
storage.updateTimestampUpdated(groupId, SnodeAPI.nowWithOffset)
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
} catch (e: Exception) {
|
||||
delegate?.handleJobFailed(this, e)
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +51,7 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
|
||||
return Data.Builder()
|
||||
.putString(ROOM, room)
|
||||
.putString(SERVER, server)
|
||||
.putString(IMAGE_ID, imageId)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -41,14 +62,16 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job {
|
||||
|
||||
private const val ROOM = "room"
|
||||
private const val SERVER = "server"
|
||||
private const val IMAGE_ID = "imageId"
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<GroupAvatarDownloadJob> {
|
||||
|
||||
override fun create(data: Data): GroupAvatarDownloadJob {
|
||||
return GroupAvatarDownloadJob(
|
||||
data.getString(SERVER),
|
||||
data.getString(ROOM),
|
||||
data.getString(SERVER)
|
||||
if (data.hasString(IMAGE_ID)) { data.getString(IMAGE_ID) } else { null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ interface Job {
|
||||
internal const val MAX_BUFFER_SIZE = 1_000_000 // bytes
|
||||
}
|
||||
|
||||
fun execute()
|
||||
fun execute(dispatcherName: String)
|
||||
|
||||
fun serialize(): Data
|
||||
|
||||
|
@@ -2,7 +2,7 @@ package org.session.libsession.messaging.jobs
|
||||
|
||||
interface JobDelegate {
|
||||
|
||||
fun handleJobSucceeded(job: Job)
|
||||
fun handleJobFailed(job: Job, error: Exception)
|
||||
fun handleJobFailedPermanently(job: Job, error: Exception)
|
||||
fun handleJobSucceeded(job: Job, dispatcherName: String)
|
||||
fun handleJobFailed(job: Job, dispatcherName: String, error: Exception)
|
||||
fun handleJobFailedPermanently(job: Job, dispatcherName: String, error: Exception)
|
||||
}
|
@@ -26,7 +26,7 @@ class JobQueue : JobDelegate {
|
||||
private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>()
|
||||
private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
|
||||
private val openGroupDispatcher = Executors.newCachedThreadPool().asCoroutineDispatcher()
|
||||
private val openGroupDispatcher = Executors.newFixedThreadPool(8).asCoroutineDispatcher()
|
||||
private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob()
|
||||
private val queue = Channel<Job>(UNLIMITED)
|
||||
@@ -53,7 +53,7 @@ class JobQueue : JobDelegate {
|
||||
}
|
||||
if (openGroupId.isNullOrEmpty()) {
|
||||
Log.e("OpenGroupDispatcher", "Open Group ID was null on ${job.javaClass.simpleName}")
|
||||
handleJobFailedPermanently(job, NullPointerException("Open Group ID was null"))
|
||||
handleJobFailedPermanently(job, name, NullPointerException("Open Group ID was null"))
|
||||
} else {
|
||||
val groupChannel = if (!openGroupChannels.containsKey(openGroupId)) {
|
||||
Log.d("OpenGroupDispatcher", "Creating ${openGroupId.hashCode()} channel")
|
||||
@@ -95,9 +95,16 @@ class JobQueue : JobDelegate {
|
||||
}
|
||||
|
||||
private fun Job.process(dispatcherName: String) {
|
||||
Log.d(dispatcherName,"processJob: ${javaClass.simpleName}")
|
||||
Log.d(dispatcherName,"processJob: ${javaClass.simpleName} (id: $id)")
|
||||
delegate = this@JobQueue
|
||||
execute()
|
||||
|
||||
try {
|
||||
execute(dispatcherName)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)")
|
||||
this@JobQueue.handleJobFailed(this, dispatcherName, e)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -118,6 +125,7 @@ class JobQueue : JobDelegate {
|
||||
is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> {
|
||||
txQueue.send(job)
|
||||
}
|
||||
is RetrieveProfileAvatarJob,
|
||||
is AttachmentDownloadJob -> {
|
||||
mediaQueue.send(job)
|
||||
}
|
||||
@@ -177,7 +185,7 @@ class JobQueue : JobDelegate {
|
||||
return
|
||||
}
|
||||
if (!pendingJobIds.add(id)) {
|
||||
Log.e("Loki","tried to re-queue pending/in-progress job")
|
||||
Log.e("Loki","tried to re-queue pending/in-progress job (id: $id)")
|
||||
return
|
||||
}
|
||||
queue.trySend(job)
|
||||
@@ -196,7 +204,7 @@ class JobQueue : JobDelegate {
|
||||
}
|
||||
}
|
||||
pendingJobs.sortedBy { it.id }.forEach { job ->
|
||||
Log.i("Loki", "Resuming pending job of type: ${job::class.simpleName}.")
|
||||
Log.i("Loki", "Resuming pending job of type: ${job::class.simpleName} (id: ${job.id}).")
|
||||
queue.trySend(job) // Offer always called on unlimited capacity
|
||||
}
|
||||
}
|
||||
@@ -217,27 +225,28 @@ class JobQueue : JobDelegate {
|
||||
GroupAvatarDownloadJob.KEY,
|
||||
BackgroundGroupAddJob.KEY,
|
||||
OpenGroupDeleteJob.KEY,
|
||||
RetrieveProfileAvatarJob.KEY,
|
||||
)
|
||||
allJobTypes.forEach { type ->
|
||||
resumePendingJobs(type)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleJobSucceeded(job: Job) {
|
||||
override fun handleJobSucceeded(job: Job, dispatcherName: String) {
|
||||
val jobId = job.id ?: return
|
||||
MessagingModuleConfiguration.shared.storage.markJobAsSucceeded(jobId)
|
||||
pendingJobIds.remove(jobId)
|
||||
}
|
||||
|
||||
override fun handleJobFailed(job: Job, error: Exception) {
|
||||
override fun handleJobFailed(job: Job, dispatcherName: String, error: Exception) {
|
||||
// Canceled
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
if (storage.isJobCanceled(job)) {
|
||||
return Log.i("Loki", "${job::class.simpleName} canceled.")
|
||||
return Log.i("Loki", "${job::class.simpleName} canceled (id: ${job.id}).")
|
||||
}
|
||||
// Message send jobs waiting for the attachment to upload
|
||||
if (job is MessageSendJob && error is MessageSendJob.AwaitingAttachmentUploadException) {
|
||||
Log.i("Loki", "Message send job waiting for attachment upload to finish.")
|
||||
Log.i("Loki", "Message send job waiting for attachment upload to finish (id: ${job.id}).")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,21 +264,22 @@ class JobQueue : JobDelegate {
|
||||
job.failureCount += 1
|
||||
|
||||
if (job.failureCount >= job.maxFailureCount) {
|
||||
handleJobFailedPermanently(job, error)
|
||||
handleJobFailedPermanently(job, dispatcherName, error)
|
||||
} else {
|
||||
storage.persistJob(job)
|
||||
val retryInterval = getRetryInterval(job)
|
||||
Log.i("Loki", "${job::class.simpleName} failed; scheduling retry (failure count is ${job.failureCount}).")
|
||||
Log.i("Loki", "${job::class.simpleName} failed (id: ${job.id}); scheduling retry (failure count is ${job.failureCount}).")
|
||||
timer.schedule(delay = retryInterval) {
|
||||
Log.i("Loki", "Retrying ${job::class.simpleName}.")
|
||||
Log.i("Loki", "Retrying ${job::class.simpleName} (id: ${job.id}).")
|
||||
queue.trySend(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleJobFailedPermanently(job: Job, error: Exception) {
|
||||
override fun handleJobFailedPermanently(job: Job, dispatcherName: String, error: Exception) {
|
||||
val jobId = job.id ?: return
|
||||
handleJobFailedPermanently(jobId)
|
||||
Log.d(dispatcherName, "permanentlyFailedJob: ${javaClass.simpleName} (id: ${job.id})")
|
||||
}
|
||||
|
||||
private fun handleJobFailedPermanently(jobId: String) {
|
||||
|
@@ -25,11 +25,11 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val
|
||||
private val OPEN_GROUP_ID_KEY = "open_group_id"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
executeAsync().get()
|
||||
override fun execute(dispatcherName: String) {
|
||||
executeAsync(dispatcherName).get()
|
||||
}
|
||||
|
||||
fun executeAsync(): Promise<Unit, Exception> {
|
||||
fun executeAsync(dispatcherName: String): Promise<Unit, Exception> {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
try {
|
||||
val isRetry: Boolean = failureCount != 0
|
||||
@@ -39,32 +39,32 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val
|
||||
val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey)
|
||||
message.serverHash = serverHash
|
||||
MessageReceiver.handle(message, proto, this.openGroupID)
|
||||
this.handleSuccess()
|
||||
this.handleSuccess(dispatcherName)
|
||||
deferred.resolve(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Couldn't receive message.", e)
|
||||
if (e is MessageReceiver.Error && !e.isRetryable) {
|
||||
Log.e("Loki", "Message receive job permanently failed.", e)
|
||||
this.handlePermanentFailure(e)
|
||||
this.handlePermanentFailure(dispatcherName, e)
|
||||
} else {
|
||||
Log.e("Loki", "Couldn't receive message.", e)
|
||||
this.handleFailure(e)
|
||||
this.handleFailure(dispatcherName, e)
|
||||
}
|
||||
deferred.resolve(Unit) // The promise is just used to keep track of when we're done
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
delegate?.handleJobSucceeded(this)
|
||||
private fun handleSuccess(dispatcherName: String) {
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
private fun handlePermanentFailure(e: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, e)
|
||||
private fun handlePermanentFailure(dispatcherName: String, e: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, e)
|
||||
}
|
||||
|
||||
private fun handleFailure(e: Exception) {
|
||||
delegate?.handleJobFailed(this, e)
|
||||
private fun handleFailure(dispatcherName: String, e: Exception) {
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
|
@@ -11,6 +11,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.snode.OnionRequestAPI
|
||||
import org.session.libsignal.utilities.HTTP
|
||||
import org.session.libsignal.utilities.Log
|
||||
|
||||
class MessageSendJob(val message: Message, val destination: Destination) : Job {
|
||||
@@ -32,7 +33,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
|
||||
private val DESTINATION_KEY = "destination"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val message = message as? VisibleMessage
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
@@ -60,21 +61,32 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
|
||||
}
|
||||
}
|
||||
if (attachmentsToUpload.isNotEmpty()) {
|
||||
this.handleFailure(AwaitingAttachmentUploadException)
|
||||
this.handleFailure(dispatcherName, AwaitingAttachmentUploadException)
|
||||
return
|
||||
} // Wait for all attachments to upload before continuing
|
||||
}
|
||||
val promise = MessageSender.send(this.message, this.destination).success {
|
||||
this.handleSuccess()
|
||||
this.handleSuccess(dispatcherName)
|
||||
}.fail { exception ->
|
||||
Log.e(TAG, "Couldn't send message due to error: $exception.")
|
||||
if (exception is MessageSender.Error) {
|
||||
if (!exception.isRetryable) { this.handlePermanentFailure(exception) }
|
||||
var logStacktrace = true
|
||||
|
||||
when (exception) {
|
||||
// No need for the stack trace for HTTP errors
|
||||
is HTTP.HTTPRequestFailedException -> {
|
||||
logStacktrace = false
|
||||
|
||||
if (exception.statusCode == 429) { this.handlePermanentFailure(dispatcherName, exception) }
|
||||
else { this.handleFailure(dispatcherName, exception) }
|
||||
}
|
||||
is MessageSender.Error -> {
|
||||
if (!exception.isRetryable) { this.handlePermanentFailure(dispatcherName, exception) }
|
||||
else { this.handleFailure(dispatcherName, exception) }
|
||||
}
|
||||
else -> this.handleFailure(dispatcherName, exception)
|
||||
}
|
||||
if (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 429) {
|
||||
this.handlePermanentFailure(exception)
|
||||
}
|
||||
this.handleFailure(exception)
|
||||
|
||||
if (logStacktrace) { Log.e(TAG, "Couldn't send message due to error", exception) }
|
||||
else { Log.e(TAG, "Couldn't send message due to error: ${exception.message}") }
|
||||
}
|
||||
try {
|
||||
promise.get()
|
||||
@@ -83,15 +95,15 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
delegate?.handleJobSucceeded(this)
|
||||
private fun handleSuccess(dispatcherName: String) {
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
private fun handlePermanentFailure(error: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, error)
|
||||
private fun handlePermanentFailure(dispatcherName: String, error: Exception) {
|
||||
delegate?.handleJobFailedPermanently(this, dispatcherName, error)
|
||||
}
|
||||
|
||||
private fun handleFailure(error: Exception) {
|
||||
private fun handleFailure(dispatcherName: String, error: Exception) {
|
||||
Log.w(TAG, "Failed to send $message::class.simpleName.")
|
||||
val message = message as? VisibleMessage
|
||||
if (message != null) {
|
||||
@@ -99,7 +111,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
|
||||
return // The message has been deleted
|
||||
}
|
||||
}
|
||||
delegate?.handleJobFailed(this, error)
|
||||
delegate?.handleJobFailed(this, dispatcherName, error)
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
|
@@ -32,7 +32,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job {
|
||||
private val MESSAGE_KEY = "message"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
val server = PushNotificationAPI.server
|
||||
val parameters = mapOf( "data" to message.data, "send_to" to message.recipient )
|
||||
val url = "${server}/notify"
|
||||
@@ -48,18 +48,18 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job {
|
||||
Log.d("Loki", "Couldn't notify PN server due to error: $exception.")
|
||||
}
|
||||
}.success {
|
||||
handleSuccess()
|
||||
handleSuccess(dispatcherName)
|
||||
}. fail {
|
||||
handleFailure(it)
|
||||
handleFailure(dispatcherName, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
delegate?.handleJobSucceeded(this)
|
||||
private fun handleSuccess(dispatcherName: String) {
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
private fun handleFailure(error: Exception) {
|
||||
delegate?.handleJobFailed(this, error)
|
||||
private fun handleFailure(dispatcherName: String, error: Exception) {
|
||||
delegate?.handleJobFailed(this, dispatcherName, error)
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
|
@@ -19,18 +19,31 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th
|
||||
override var failureCount: Int = 0
|
||||
override val maxFailureCount: Int = 1
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val numberToDelete = messageServerIds.size
|
||||
Log.d(TAG, "Deleting $numberToDelete messages")
|
||||
var numberDeleted = 0
|
||||
messageServerIds.forEach { serverId ->
|
||||
val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach
|
||||
dataProvider.deleteMessage(messageId, isSms)
|
||||
numberDeleted++
|
||||
|
||||
// FIXME: This entire process should probably run in a transaction (with the attachment deletion happening only if it succeeded)
|
||||
try {
|
||||
val messageIds = dataProvider.getMessageIDs(messageServerIds.toList(), threadId)
|
||||
|
||||
// Delete the SMS messages
|
||||
if (messageIds.first.isNotEmpty()) {
|
||||
dataProvider.deleteMessages(messageIds.first, threadId, true)
|
||||
}
|
||||
|
||||
// Delete the MMS messages
|
||||
if (messageIds.second.isNotEmpty()) {
|
||||
dataProvider.deleteMessages(messageIds.second, threadId, false)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Deleted ${messageIds.first.size + messageIds.second.size} messages successfully")
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
delegate?.handleJobFailed(this, dispatcherName, e)
|
||||
}
|
||||
Log.d(TAG, "Deleted $numberDeleted messages successfully")
|
||||
delegate?.handleJobSucceeded(this)
|
||||
}
|
||||
|
||||
override fun serialize(): Data = Data.Builder()
|
||||
|
@@ -0,0 +1,105 @@
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
import android.text.TextUtils
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.utilities.DownloadUtilities.downloadFile
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId
|
||||
import org.session.libsession.utilities.Util.copy
|
||||
import org.session.libsession.utilities.Util.equals
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.streams.ProfileCipherInputStream
|
||||
import org.session.libsignal.utilities.Log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.security.SecureRandom
|
||||
|
||||
class RetrieveProfileAvatarJob(private val profileAvatar: String?, private val recipientAddress: Address): Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
override val maxFailureCount: Int = 0
|
||||
|
||||
companion object {
|
||||
val TAG = RetrieveProfileAvatarJob::class.simpleName
|
||||
val KEY: String = "RetrieveProfileAvatarJob"
|
||||
|
||||
// Keys used for database storage
|
||||
private const val PROFILE_AVATAR_KEY = "profileAvatar"
|
||||
private const val RECEIPIENT_ADDRESS_KEY = "recipient"
|
||||
}
|
||||
|
||||
override fun execute(dispatcherName: String) {
|
||||
val context = MessagingModuleConfiguration.shared.context
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val recipient = Recipient.from(context, recipientAddress, true)
|
||||
val profileKey = recipient.resolve().profileKey
|
||||
|
||||
if (profileKey == null || (profileKey.size != 32 && profileKey.size != 16)) {
|
||||
Log.w(TAG, "Recipient profile key is gone!")
|
||||
return
|
||||
}
|
||||
|
||||
if (AvatarHelper.avatarFileExists(context, recipient.resolve().address) && equals(profileAvatar, recipient.resolve().profileAvatar)) {
|
||||
Log.w(TAG, "Already retrieved profile avatar: $profileAvatar")
|
||||
return
|
||||
}
|
||||
|
||||
if (profileAvatar.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Removing profile avatar for: " + recipient.address.serialize())
|
||||
|
||||
if (recipient.isLocalNumber) {
|
||||
setProfileAvatarId(context, SecureRandom().nextInt())
|
||||
setProfilePictureURL(context, null)
|
||||
}
|
||||
|
||||
AvatarHelper.delete(context, recipient.address)
|
||||
storage.setProfileAvatar(recipient, null)
|
||||
return
|
||||
}
|
||||
|
||||
val downloadDestination = File.createTempFile("avatar", ".jpg", context.cacheDir)
|
||||
|
||||
try {
|
||||
downloadFile(downloadDestination, profileAvatar)
|
||||
val avatarStream: InputStream = ProfileCipherInputStream(FileInputStream(downloadDestination), profileKey)
|
||||
val decryptDestination = File.createTempFile("avatar", ".jpg", context.cacheDir)
|
||||
copy(avatarStream, FileOutputStream(decryptDestination))
|
||||
decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address))
|
||||
} finally {
|
||||
downloadDestination.delete()
|
||||
}
|
||||
|
||||
if (recipient.isLocalNumber) {
|
||||
setProfileAvatarId(context, SecureRandom().nextInt())
|
||||
setProfilePictureURL(context, profileAvatar)
|
||||
}
|
||||
|
||||
storage.setProfileAvatar(recipient, profileAvatar)
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
return Data.Builder()
|
||||
.putString(PROFILE_AVATAR_KEY, profileAvatar)
|
||||
.putString(RECEIPIENT_ADDRESS_KEY, recipientAddress.serialize())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String {
|
||||
return KEY
|
||||
}
|
||||
|
||||
class Factory: Job.Factory<RetrieveProfileAvatarJob> {
|
||||
override fun create(data: Data): RetrieveProfileAvatarJob {
|
||||
val profileAvatar = if (data.hasString(PROFILE_AVATAR_KEY)) { data.getString(PROFILE_AVATAR_KEY) } else { null }
|
||||
val recipientAddress = Address.fromSerialized(data.getString(RECEIPIENT_ADDRESS_KEY))
|
||||
return RetrieveProfileAvatarJob(profileAvatar, recipientAddress)
|
||||
}
|
||||
}
|
||||
}
|
@@ -20,7 +20,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job {
|
||||
const val THREAD_LENGTH_TRIGGER_SIZE = 2000
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
override fun execute(dispatcherName: String) {
|
||||
val context = MessagingModuleConfiguration.shared.context
|
||||
val trimmingEnabled = TextSecurePreferences.isThreadLengthTrimmingEnabled(context)
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
@@ -29,7 +29,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job {
|
||||
val oldestMessageTime = System.currentTimeMillis() - TRIM_TIME_LIMIT
|
||||
storage.trimThreadBefore(threadId, oldestMessageTime)
|
||||
}
|
||||
delegate?.handleJobSucceeded(this)
|
||||
delegate?.handleJobSucceeded(this, dispatcherName)
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
|
@@ -7,13 +7,13 @@ import org.session.libsignal.utilities.toHexString
|
||||
|
||||
sealed class Destination {
|
||||
|
||||
class Contact(var publicKey: String) : Destination() {
|
||||
data class Contact(var publicKey: String) : Destination() {
|
||||
internal constructor(): this("")
|
||||
}
|
||||
class ClosedGroup(var groupPublicKey: String) : Destination() {
|
||||
data class ClosedGroup(var groupPublicKey: String) : Destination() {
|
||||
internal constructor(): this("")
|
||||
}
|
||||
class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() {
|
||||
data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() {
|
||||
internal constructor(): this("", "")
|
||||
}
|
||||
|
||||
|
@@ -1,20 +1,26 @@
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsession.messaging.messages.visible.Profile
|
||||
import org.session.libsignal.protos.SignalServiceProtos
|
||||
import org.session.libsignal.utilities.Log
|
||||
|
||||
class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() {
|
||||
class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = null) : ControlMessage() {
|
||||
|
||||
override val isSelfSendValid: Boolean = true
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val profileProto = SignalServiceProtos.DataMessage.LokiProfile.newBuilder()
|
||||
profile?.displayName?.let { profileProto.displayName = it }
|
||||
profile?.profilePictureURL?.let { profileProto.profilePicture = it }
|
||||
val messageRequestResponseProto = SignalServiceProtos.MessageRequestResponse.newBuilder()
|
||||
.setIsApproved(isApproved)
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
.setProfile(profileProto.build())
|
||||
profile?.profileKey?.let { messageRequestResponseProto.profileKey = ByteString.copyFrom(it) }
|
||||
return try {
|
||||
contentProto.messageRequestResponse = messageRequestResponseProto.build()
|
||||
contentProto.setExpirationConfigurationIfNeeded(threadID)
|
||||
contentProto.build()
|
||||
messageRequestResponseProto.messageRequestResponse = messageRequestResponseProto.build()
|
||||
messageRequestResponseProto.setExpirationConfigurationIfNeeded(threadID)
|
||||
messageRequestResponseProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct message request response proto from: $this")
|
||||
null
|
||||
@@ -27,7 +33,13 @@ class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() {
|
||||
fun fromProto(proto: SignalServiceProtos.Content): MessageRequestResponse? {
|
||||
val messageRequestResponseProto = if (proto.hasMessageRequestResponse()) proto.messageRequestResponse else return null
|
||||
val isApproved = messageRequestResponseProto.isApproved
|
||||
return MessageRequestResponse(isApproved)
|
||||
val profileProto = messageRequestResponseProto.profile
|
||||
val profile = Profile().apply {
|
||||
displayName = profileProto.displayName
|
||||
profileKey = if (messageRequestResponseProto.hasProfileKey()) messageRequestResponseProto.profileKey.toByteArray() else null
|
||||
profilePictureURL = profileProto.profilePicture
|
||||
}
|
||||
return MessageRequestResponse(isApproved, profile)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -30,6 +30,7 @@ public class IncomingMediaMessage {
|
||||
private final boolean expirationUpdate;
|
||||
private final boolean unidentified;
|
||||
private final boolean messageRequestResponse;
|
||||
private final boolean hasMention;
|
||||
|
||||
private final DataExtractionNotificationInfoMessage dataExtractionNotification;
|
||||
private final QuoteModel quote;
|
||||
@@ -46,6 +47,7 @@ public class IncomingMediaMessage {
|
||||
boolean expirationUpdate,
|
||||
boolean unidentified,
|
||||
boolean messageRequestResponse,
|
||||
boolean hasMention,
|
||||
Optional<String> body,
|
||||
Optional<SignalServiceGroup> group,
|
||||
Optional<List<SignalServiceAttachment>> attachments,
|
||||
@@ -66,6 +68,7 @@ public class IncomingMediaMessage {
|
||||
this.quote = quote.orNull();
|
||||
this.unidentified = unidentified;
|
||||
this.messageRequestResponse = messageRequestResponse;
|
||||
this.hasMention = hasMention;
|
||||
|
||||
if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.INSTANCE.getEncodedId(group.get()));
|
||||
else this.groupId = null;
|
||||
@@ -85,7 +88,8 @@ public class IncomingMediaMessage {
|
||||
Optional<List<LinkPreview>> linkPreviews)
|
||||
{
|
||||
return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, expireStartedAt, false,
|
||||
false, false, Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent());
|
||||
false, false, message.getHasMention(), Optional.fromNullable(message.getText()),
|
||||
group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent());
|
||||
}
|
||||
|
||||
public int getSubscriptionId() {
|
||||
@@ -132,6 +136,10 @@ public class IncomingMediaMessage {
|
||||
return groupId != null;
|
||||
}
|
||||
|
||||
public boolean hasMention() {
|
||||
return hasMention;
|
||||
}
|
||||
|
||||
public boolean isScreenshotDataExtraction() {
|
||||
if (dataExtractionNotification == null) return false;
|
||||
else {
|
||||
|
@@ -44,24 +44,25 @@ public class IncomingTextMessage implements Parcelable {
|
||||
private final long expireStartedAt;
|
||||
private final boolean unidentified;
|
||||
private final int callType;
|
||||
private final boolean hasMention;
|
||||
|
||||
private boolean isOpenGroupInvitation = false;
|
||||
|
||||
public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis,
|
||||
String encodedBody, Optional<SignalServiceGroup> group,
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified) {
|
||||
this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, -1);
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified, boolean hasMention) {
|
||||
this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, -1, hasMention);
|
||||
}
|
||||
|
||||
public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis,
|
||||
String encodedBody, Optional<SignalServiceGroup> group,
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified, int callType) {
|
||||
this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, callType, true);
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention) {
|
||||
this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, callType, hasMention, true);
|
||||
}
|
||||
|
||||
public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis,
|
||||
String encodedBody, Optional<SignalServiceGroup> group,
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean isPush) {
|
||||
long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention, boolean isPush) {
|
||||
this.message = encodedBody;
|
||||
this.sender = sender;
|
||||
this.senderDeviceId = senderDeviceId;
|
||||
@@ -76,6 +77,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
this.expireStartedAt = expireStartedAt;
|
||||
this.unidentified = unidentified;
|
||||
this.callType = callType;
|
||||
this.hasMention = hasMention;
|
||||
|
||||
if (group.isPresent()) {
|
||||
this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get()));
|
||||
@@ -101,6 +103,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
this.unidentified = in.readInt() == 1;
|
||||
this.isOpenGroupInvitation = in.readInt() == 1;
|
||||
this.callType = in.readInt();
|
||||
this.hasMention = in.readInt() == 1;
|
||||
}
|
||||
|
||||
public IncomingTextMessage(IncomingTextMessage base, String newBody) {
|
||||
@@ -120,6 +123,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
this.unidentified = base.isUnidentified();
|
||||
this.isOpenGroupInvitation = base.isOpenGroupInvitation();
|
||||
this.callType = base.callType;
|
||||
this.hasMention = base.hasMention;
|
||||
}
|
||||
|
||||
public static IncomingTextMessage from(VisibleMessage message,
|
||||
@@ -128,7 +132,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
long expiresInMillis,
|
||||
long expireStartedAt)
|
||||
{
|
||||
return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, expireStartedAt, false);
|
||||
return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, expireStartedAt, false, message.getHasMention());
|
||||
}
|
||||
|
||||
public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation,
|
||||
@@ -141,7 +145,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
if (url == null || name == null) { return null; }
|
||||
// FIXME: Doing toJSON() to get the body here is weird
|
||||
String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON();
|
||||
IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false);
|
||||
IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false, false);
|
||||
incomingTextMessage.isOpenGroupInvitation = true;
|
||||
return incomingTextMessage;
|
||||
}
|
||||
@@ -152,7 +156,7 @@ public class IncomingTextMessage implements Parcelable {
|
||||
long sentTimestamp,
|
||||
long expiresInMillis,
|
||||
long expireStartedAt) {
|
||||
return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false);
|
||||
return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false, false);
|
||||
}
|
||||
|
||||
public int getSubscriptionId() {
|
||||
@@ -221,6 +225,8 @@ public class IncomingTextMessage implements Parcelable {
|
||||
|
||||
public boolean isOpenGroupInvitation() { return isOpenGroupInvitation; }
|
||||
|
||||
public boolean hasMention() { return hasMention; }
|
||||
|
||||
public boolean isCallInfo() {
|
||||
int callMessageTypeLength = CallMessageType.values().length;
|
||||
return callType >= 0 && callType < callMessageTypeLength;
|
||||
@@ -254,5 +260,6 @@ public class IncomingTextMessage implements Parcelable {
|
||||
out.writeInt(unidentified ? 1 : 0);
|
||||
out.writeInt(isOpenGroupInvitation ? 1 : 0);
|
||||
out.writeInt(callType);
|
||||
out.writeInt(hasMention ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
@@ -90,8 +90,8 @@ public class OutgoingMediaMessage {
|
||||
previews = Collections.singletonList(linkPreview);
|
||||
}
|
||||
return new OutgoingMediaMessage(recipient, message.getText(), attachments, message.getSentTimestamp(), -1,
|
||||
expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, Collections.emptyList(),
|
||||
previews, Collections.emptyList(), Collections.emptyList());
|
||||
expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote,
|
||||
Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList());
|
||||
}
|
||||
|
||||
public Recipient getRecipient() {
|
||||
|
@@ -25,7 +25,7 @@ class Profile() {
|
||||
}
|
||||
}
|
||||
|
||||
internal constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() {
|
||||
constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() {
|
||||
this.displayName = displayName
|
||||
this.profileKey = profileKey
|
||||
this.profilePictureURL = profilePictureURL
|
||||
|
@@ -8,19 +8,22 @@ import org.session.libsignal.protos.SignalServiceProtos
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
|
||||
|
||||
class VisibleMessage : Message() {
|
||||
/** In the case of a sync message, the public key of the person the message was targeted at.
|
||||
*
|
||||
* **Note:** `nil` if this isn't a sync message.
|
||||
*/
|
||||
var syncTarget: String? = null
|
||||
var text: String? = null
|
||||
val attachmentIDs: MutableList<Long> = mutableListOf()
|
||||
var quote: Quote? = null
|
||||
var linkPreview: LinkPreview? = null
|
||||
var profile: Profile? = null
|
||||
var openGroupInvitation: OpenGroupInvitation? = null
|
||||
var reaction: Reaction? = null
|
||||
/**
|
||||
* @param syncTarget In the case of a sync message, the public key of the person the message was targeted at.
|
||||
*
|
||||
* **Note:** `nil` if this isn't a sync message.
|
||||
*/
|
||||
class VisibleMessage(
|
||||
var syncTarget: String? = null,
|
||||
var text: String? = null,
|
||||
val attachmentIDs: MutableList<Long> = mutableListOf(),
|
||||
var quote: Quote? = null,
|
||||
var linkPreview: LinkPreview? = null,
|
||||
var profile: Profile? = null,
|
||||
var openGroupInvitation: OpenGroupInvitation? = null,
|
||||
var reaction: Reaction? = null,
|
||||
var hasMention: Boolean = false
|
||||
) : Message() {
|
||||
|
||||
override val isSelfSendValid: Boolean = true
|
||||
|
||||
|
@@ -11,16 +11,19 @@ data class OpenGroup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val publicKey: String,
|
||||
val imageId: String?,
|
||||
val infoUpdates: Int,
|
||||
val canWrite: Boolean,
|
||||
) {
|
||||
|
||||
constructor(server: String, room: String, name: String, infoUpdates: Int, publicKey: String) : this(
|
||||
constructor(server: String, room: String, publicKey: String, name: String, imageId: String?, canWrite: Boolean, infoUpdates: Int) : this(
|
||||
server = server,
|
||||
room = room,
|
||||
id = "$server.$room",
|
||||
name = name,
|
||||
publicKey = publicKey,
|
||||
imageId = imageId,
|
||||
infoUpdates = infoUpdates,
|
||||
canWrite = canWrite
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -29,13 +32,14 @@ data class OpenGroup(
|
||||
return try {
|
||||
val json = JsonUtil.fromJson(jsonAsString)
|
||||
if (!json.has("room")) return null
|
||||
val room = json.get("room").asText().toLowerCase(Locale.US)
|
||||
val server = json.get("server").asText().toLowerCase(Locale.US)
|
||||
val room = json.get("room").asText().lowercase(Locale.US)
|
||||
val server = json.get("server").asText().lowercase(Locale.US)
|
||||
val displayName = json.get("displayName").asText()
|
||||
val publicKey = json.get("publicKey").asText()
|
||||
val imageId = if (json.hasNonNull("imageId")) { json.get("imageId")?.asText() } else { null }
|
||||
val canWrite = json.get("canWrite")?.asText()?.toBoolean() ?: true
|
||||
val infoUpdates = json.get("infoUpdates")?.asText()?.toIntOrNull() ?: 0
|
||||
val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList()
|
||||
OpenGroup(server, room, displayName, infoUpdates, publicKey)
|
||||
OpenGroup(server = server, room = room, name = displayName, publicKey = publicKey, imageId = imageId, canWrite = canWrite, infoUpdates = infoUpdates)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e);
|
||||
null
|
||||
@@ -53,12 +57,14 @@ data class OpenGroup(
|
||||
}
|
||||
}
|
||||
|
||||
fun toJson(): Map<String,String> = mapOf(
|
||||
fun toJson(): Map<String,String?> = mapOf(
|
||||
"room" to room,
|
||||
"server" to server,
|
||||
"displayName" to name,
|
||||
"publicKey" to publicKey,
|
||||
"displayName" to name,
|
||||
"imageId" to imageId,
|
||||
"infoUpdates" to infoUpdates.toString(),
|
||||
"canWrite" to canWrite.toString()
|
||||
)
|
||||
|
||||
val joinURL: String get() = "$server/$room?public_key=$publicKey"
|
||||
|
@@ -23,6 +23,7 @@ import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.snode.OnionRequestAPI
|
||||
import org.session.libsession.snode.OnionResponse
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.Base64.decode
|
||||
import org.session.libsignal.utilities.Base64.encodeBytes
|
||||
@@ -91,7 +92,7 @@ object OpenGroupApi {
|
||||
val created: Long = 0,
|
||||
val activeUsers: Int = 0,
|
||||
val activeUsersCutoff: Int = 0,
|
||||
val imageId: Long? = null,
|
||||
val imageId: String? = null,
|
||||
val pinnedMessages: List<PinnedMessage> = emptyList(),
|
||||
val admin: Boolean = false,
|
||||
val globalAdmin: Boolean = false,
|
||||
@@ -108,7 +109,24 @@ object OpenGroupApi {
|
||||
val defaultWrite: Boolean = false,
|
||||
val upload: Boolean = false,
|
||||
val defaultUpload: Boolean = false,
|
||||
)
|
||||
) {
|
||||
fun toPollInfo() = RoomPollInfo(
|
||||
token = token,
|
||||
activeUsers = activeUsers,
|
||||
admin = admin,
|
||||
globalAdmin = globalAdmin,
|
||||
moderator = moderator,
|
||||
globalModerator = globalModerator,
|
||||
read = read,
|
||||
defaultRead = defaultRead,
|
||||
defaultAccessible = defaultAccessible,
|
||||
write = write,
|
||||
defaultWrite = defaultWrite,
|
||||
upload = upload,
|
||||
defaultUpload = defaultUpload,
|
||||
details = this
|
||||
)
|
||||
}
|
||||
|
||||
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||
data class PinnedMessage(
|
||||
@@ -148,7 +166,7 @@ object OpenGroupApi {
|
||||
)
|
||||
|
||||
enum class Capability {
|
||||
BLIND, REACTIONS
|
||||
SOGS, BLIND, REACTIONS
|
||||
}
|
||||
|
||||
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||
@@ -303,7 +321,7 @@ object OpenGroupApi {
|
||||
val headers = request.headers.toMutableMap()
|
||||
if (request.isAuthRequired) {
|
||||
val nonce = sodium.nonce(16)
|
||||
val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
|
||||
val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset)
|
||||
var pubKey = ""
|
||||
var signature = ByteArray(Sign.BYTES)
|
||||
var bodyHash = ByteArray(0)
|
||||
@@ -337,7 +355,7 @@ object OpenGroupApi {
|
||||
.plus(request.verb.rawValue.toByteArray())
|
||||
.plus("/${request.endpoint.value}".toByteArray())
|
||||
.plus(bodyHash)
|
||||
if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
|
||||
if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
|
||||
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
|
||||
pubKey = SessionId(
|
||||
IdPrefix.BLINDED,
|
||||
@@ -383,7 +401,11 @@ object OpenGroupApi {
|
||||
}
|
||||
return if (request.useOnionRouting) {
|
||||
OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e ->
|
||||
Log.e("SOGS", "Failed onion request", e)
|
||||
when (e) {
|
||||
// No need for the stack trace for HTTP errors
|
||||
is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}")
|
||||
else -> Log.e("SOGS", "Failed onion request", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
|
||||
@@ -395,13 +417,13 @@ object OpenGroupApi {
|
||||
fun downloadOpenGroupProfilePicture(
|
||||
server: String,
|
||||
roomID: String,
|
||||
imageId: Long
|
||||
imageId: String
|
||||
): Promise<ByteArray, Exception> {
|
||||
val request = Request(
|
||||
verb = GET,
|
||||
room = roomID,
|
||||
server = server,
|
||||
endpoint = Endpoint.RoomFileIndividual(roomID, imageId.toString())
|
||||
endpoint = Endpoint.RoomFileIndividual(roomID, imageId)
|
||||
)
|
||||
return getResponseBody(request)
|
||||
}
|
||||
@@ -794,16 +816,14 @@ object OpenGroupApi {
|
||||
|
||||
private fun sequentialBatch(
|
||||
server: String,
|
||||
requests: MutableList<BatchRequestInfo<*>>,
|
||||
authRequired: Boolean = true
|
||||
requests: MutableList<BatchRequestInfo<*>>
|
||||
): Promise<List<BatchResponse<*>>, Exception> {
|
||||
val request = Request(
|
||||
verb = POST,
|
||||
room = null,
|
||||
server = server,
|
||||
endpoint = Endpoint.Sequence,
|
||||
parameters = requests.map { it.request },
|
||||
isAuthRequired = authRequired
|
||||
parameters = requests.map { it.request }
|
||||
)
|
||||
return getBatchResponseJson(request, requests)
|
||||
}
|
||||
@@ -912,8 +932,7 @@ object OpenGroupApi {
|
||||
|
||||
fun getCapabilitiesAndRoomInfo(
|
||||
room: String,
|
||||
server: String,
|
||||
authRequired: Boolean = true
|
||||
server: String
|
||||
): Promise<Pair<Capabilities, RoomInfo>, Exception> {
|
||||
val requests = mutableListOf<BatchRequestInfo<*>>(
|
||||
BatchRequestInfo(
|
||||
@@ -933,7 +952,7 @@ object OpenGroupApi {
|
||||
responseType = object : TypeReference<RoomInfo>(){}
|
||||
)
|
||||
)
|
||||
return sequentialBatch(server, requests, authRequired).map {
|
||||
return sequentialBatch(server, requests).map {
|
||||
val capabilities = it.firstOrNull()?.body as? Capabilities ?: throw Error.ParsingFailed
|
||||
val roomInfo = it.lastOrNull()?.body as? RoomInfo ?: throw Error.ParsingFailed
|
||||
capabilities to roomInfo
|
||||
|
@@ -14,6 +14,7 @@ import org.session.libsession.messaging.messages.control.UnsendRequest
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsignal.crypto.PushTransportDetails
|
||||
import org.session.libsignal.protos.SignalServiceProtos
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
@@ -155,7 +156,7 @@ object MessageReceiver {
|
||||
message.sender = sender
|
||||
message.recipient = userPublicKey
|
||||
message.sentTimestamp = envelope.timestamp
|
||||
message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else System.currentTimeMillis()
|
||||
message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else SnodeAPI.nowWithOffset
|
||||
message.groupPublicKey = groupPublicKey
|
||||
message.openGroupServerMessageID = openGroupServerID
|
||||
// Validate
|
||||
|
@@ -12,9 +12,14 @@ import org.session.libsession.messaging.messages.control.CallMessage
|
||||
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
|
||||
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||
import org.session.libsession.messaging.messages.control.MessageRequestResponse
|
||||
import org.session.libsession.messaging.messages.control.UnsendRequest
|
||||
import org.session.libsession.messaging.messages.control.CallMessage
|
||||
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
|
||||
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||
import org.session.libsession.messaging.messages.control.UnsendRequest
|
||||
import org.session.libsession.messaging.messages.visible.LinkPreview
|
||||
import org.session.libsession.messaging.messages.visible.Profile
|
||||
import org.session.libsession.messaging.messages.visible.Quote
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
@@ -62,11 +67,11 @@ object MessageSender {
|
||||
}
|
||||
|
||||
// Convenience
|
||||
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
|
||||
fun send(message: Message, destination: Destination, isSyncMessage: Boolean = false): Promise<Unit, Exception> {
|
||||
return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) {
|
||||
sendToOpenGroupDestination(destination, message)
|
||||
} else {
|
||||
sendToSnodeDestination(destination, message)
|
||||
sendToSnodeDestination(destination, message, isSyncMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,16 +83,16 @@ object MessageSender {
|
||||
val userPublicKey = storage.getUserPublicKey()
|
||||
// Set the timestamp, sender and recipient
|
||||
if (message.sentTimestamp == null) {
|
||||
message.sentTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset // Visible messages will already have their sent timestamp set
|
||||
message.sentTimestamp = SnodeAPI.nowWithOffset // Visible messages will already have their sent timestamp set
|
||||
}
|
||||
|
||||
val messageSendTime = System.currentTimeMillis()
|
||||
val messageSendTime = SnodeAPI.nowWithOffset
|
||||
|
||||
message.sender = userPublicKey
|
||||
val isSelfSend = (message.recipient == userPublicKey)
|
||||
// Set the failure handler (need it here already for precondition failure handling)
|
||||
fun handleFailure(error: Exception) {
|
||||
handleFailedMessageSend(message, error)
|
||||
handleFailedMessageSend(message, error, isSyncMessage)
|
||||
if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) {
|
||||
SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!)
|
||||
}
|
||||
@@ -114,14 +119,10 @@ object MessageSender {
|
||||
}
|
||||
// Attach the user's profile if needed
|
||||
if (message is VisibleMessage) {
|
||||
val displayName = storage.getUserDisplayName()!!
|
||||
val profileKey = storage.getUserProfileKey()
|
||||
val profilePictureUrl = storage.getUserProfilePictureURL()
|
||||
if (profileKey != null && profilePictureUrl != null) {
|
||||
message.profile = Profile(displayName, profileKey, profilePictureUrl)
|
||||
} else {
|
||||
message.profile = Profile(displayName)
|
||||
}
|
||||
message.profile = storage.getUserProfile()
|
||||
}
|
||||
if (message is MessageRequestResponse) {
|
||||
message.profile = storage.getUserProfile()
|
||||
}
|
||||
// Convert it to protobuf
|
||||
val proto = message.toProto() ?: throw Error.ProtoConversionFailed
|
||||
@@ -190,7 +191,20 @@ object MessageSender {
|
||||
val hash = it["hash"] as? String
|
||||
message.serverHash = hash
|
||||
handleSuccessfulMessageSend(message, destination, isSyncMessage)
|
||||
val shouldNotify = ((message is VisibleMessage || message is UnsendRequest || message is CallMessage) && !isSyncMessage)
|
||||
|
||||
val shouldNotify: Boolean = when (message) {
|
||||
is VisibleMessage, is UnsendRequest -> !isSyncMessage
|
||||
is CallMessage -> {
|
||||
// Note: Other 'CallMessage' types are too big to send as push notifications
|
||||
// so only send the 'preOffer' message as a notification
|
||||
when (message.type) {
|
||||
SignalServiceProtos.CallMessage.Type.PRE_OFFER -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
/*
|
||||
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) {
|
||||
shouldNotify = true
|
||||
@@ -233,7 +247,7 @@ object MessageSender {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
if (message.sentTimestamp == null) {
|
||||
message.sentTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
message.sentTimestamp = SnodeAPI.nowWithOffset
|
||||
}
|
||||
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
|
||||
var serverCapabilities = listOf<String>()
|
||||
@@ -271,14 +285,7 @@ object MessageSender {
|
||||
try {
|
||||
// Attach the user's profile if needed
|
||||
if (message is VisibleMessage) {
|
||||
val displayName = storage.getUserDisplayName()!!
|
||||
val profileKey = storage.getUserProfileKey()
|
||||
val profilePictureUrl = storage.getUserProfilePictureURL()
|
||||
if (profileKey != null && profilePictureUrl != null) {
|
||||
message.profile = Profile(displayName, profileKey, profilePictureUrl)
|
||||
} else {
|
||||
message.profile = Profile(displayName)
|
||||
}
|
||||
message.profile = storage.getUserProfile()
|
||||
}
|
||||
when (destination) {
|
||||
is Destination.OpenGroup -> {
|
||||
@@ -391,16 +398,23 @@ object MessageSender {
|
||||
// • the destination was a contact
|
||||
// • we didn't sync it already
|
||||
if (destination is Destination.Contact && !isSyncMessage) {
|
||||
if (message is VisibleMessage) { message.syncTarget = destination.publicKey }
|
||||
if (message is ExpirationTimerUpdate) { message.syncTarget = destination.publicKey }
|
||||
if (message is VisibleMessage) message.syncTarget = destination.publicKey
|
||||
if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey
|
||||
|
||||
storage.markAsSyncing(message.sentTimestamp!!, userPublicKey)
|
||||
sendToSnodeDestination(Destination.Contact(userPublicKey), message, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleFailedMessageSend(message: Message, error: Exception) {
|
||||
fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val userPublicKey = storage.getUserPublicKey()!!
|
||||
storage.setErrorMessage(message.sentTimestamp!!, message.sender?:userPublicKey, error)
|
||||
|
||||
val timestamp = message.sentTimestamp!!
|
||||
val author = message.sender ?: userPublicKey
|
||||
|
||||
if (isSyncMessage) storage.markAsSyncFailed(timestamp, author, error)
|
||||
else storage.markAsSentFailed(timestamp, author, error)
|
||||
}
|
||||
|
||||
// Convenience
|
||||
|
@@ -49,21 +49,12 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str
|
||||
val admins = setOf( userPublicKey )
|
||||
val adminsAsData = admins.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }
|
||||
storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }),
|
||||
null, null, LinkedList(admins.map { Address.fromSerialized(it) }), System.currentTimeMillis())
|
||||
null, null, LinkedList(admins.map { Address.fromSerialized(it) }), SnodeAPI.nowWithOffset)
|
||||
storage.setProfileSharing(Address.fromSerialized(groupID), true)
|
||||
|
||||
// Send a closed group update message to all members individually
|
||||
val closedGroupUpdateKind = ClosedGroupControlMessage.Kind.New(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), name, encryptionKeyPair, membersAsData, adminsAsData, 0)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
for (member in members) {
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind, groupID)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
try {
|
||||
sendNonDurably(closedGroupControlMessage, Address.fromSerialized(member)).get()
|
||||
} catch (e: Exception) {
|
||||
deferred.reject(e)
|
||||
return@queue
|
||||
}
|
||||
}
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
|
||||
// Add the group to the user's set of public keys to poll for
|
||||
storage.addClosedGroupPublicKey(groupPublicKey)
|
||||
@@ -72,6 +63,24 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str
|
||||
// Notify the user
|
||||
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
|
||||
storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTime)
|
||||
|
||||
for (member in members) {
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind, groupID)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
try {
|
||||
sendNonDurably(closedGroupControlMessage, Address.fromSerialized(member)).get()
|
||||
} catch (e: Exception) {
|
||||
// We failed to properly create the group so delete it's associated data (in the past
|
||||
// we didn't create this data until the messages successfully sent but this resulted
|
||||
// in race conditions due to the `NEW` message sent to our own swarm)
|
||||
storage.removeClosedGroupPublicKey(groupPublicKey)
|
||||
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
|
||||
storage.deleteConversation(threadID)
|
||||
deferred.reject(e)
|
||||
return@queue
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the PN server
|
||||
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey)
|
||||
// Start polling
|
||||
@@ -113,7 +122,7 @@ fun MessageSender.setName(groupPublicKey: String, newName: String) {
|
||||
val admins = group.admins.map { it.serialize() }
|
||||
// Send the update to the group
|
||||
val kind = ClosedGroupControlMessage.Kind.NameChange(newName)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
send(closedGroupControlMessage, Address.fromSerialized(groupID))
|
||||
@@ -153,7 +162,7 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>)
|
||||
val name = group.title
|
||||
// Send the update to the group
|
||||
val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersAdded(newMembersAsData)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
send(closedGroupControlMessage, Address.fromSerialized(groupID))
|
||||
@@ -167,7 +176,7 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>)
|
||||
// updates from before that timestamp. By setting the timestamp of the message below to a value
|
||||
// greater than that of the `MembersAdded` message, we ensure that newly added members ignore
|
||||
// the `MembersAdded` message.
|
||||
closedGroupControlMessage.sentTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
closedGroupControlMessage.sentTimestamp = SnodeAPI.nowWithOffset
|
||||
send(closedGroupControlMessage, Address.fromSerialized(member))
|
||||
}
|
||||
// Notify the user
|
||||
@@ -208,7 +217,7 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St
|
||||
val name = group.title
|
||||
// Send the update to the group
|
||||
val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersRemoved(removeMembersAsData)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
send(closedGroupControlMessage, Address.fromSerialized(groupID))
|
||||
@@ -239,7 +248,7 @@ fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Pro
|
||||
val name = group.title
|
||||
// Send the update to the group
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft(), groupID)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
storage.setActive(groupID, false)
|
||||
sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID)).success {
|
||||
@@ -298,7 +307,7 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe
|
||||
ClosedGroupControlMessage.KeyPairWrapper(publicKey, ByteString.copyFrom(ciphertext))
|
||||
}
|
||||
val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), wrappers)
|
||||
val sentTime = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sentTime = SnodeAPI.nowWithOffset
|
||||
val closedGroupControlMessage = ClosedGroupControlMessage(kind, null)
|
||||
closedGroupControlMessage.sentTimestamp = sentTime
|
||||
return if (force) {
|
||||
|
@@ -5,6 +5,7 @@ import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.control.CallMessage
|
||||
@@ -31,6 +32,12 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.messaging.utilities.WebRtcUtils
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
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.Address
|
||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
@@ -203,28 +210,30 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
|
||||
ProfileKeyUtil.setEncodedProfileKey(context, profileKey)
|
||||
profileManager.setProfileKey(context, recipient, message.profileKey)
|
||||
if (!message.profilePicture.isNullOrEmpty() && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) {
|
||||
storage.setUserProfilePictureURL(message.profilePicture!!)
|
||||
JobQueue.shared.add(RetrieveProfileAvatarJob(message.profilePicture!!, recipient.address))
|
||||
}
|
||||
}
|
||||
storage.addContacts(message.contacts)
|
||||
}
|
||||
|
||||
fun MessageReceiver.handleUnsendRequest(message: UnsendRequest) {
|
||||
fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? {
|
||||
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
|
||||
if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return }
|
||||
if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return null }
|
||||
val context = MessagingModuleConfiguration.shared.context
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val timestamp = message.timestamp ?: return
|
||||
val author = message.author ?: return
|
||||
val messageIdToDelete = storage.getMessageIdInDatabase(timestamp, author) ?: return
|
||||
val timestamp = message.timestamp ?: return null
|
||||
val author = message.author ?: return null
|
||||
val messageIdToDelete = storage.getMessageIdInDatabase(timestamp, author) ?: return null
|
||||
messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash ->
|
||||
SnodeAPI.deleteMessage(author, listOf(serverHash))
|
||||
}
|
||||
messageDataProvider.updateMessageAsDeleted(timestamp, author)
|
||||
val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author)
|
||||
if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) {
|
||||
SSKEnvironment.shared.notificationManager.updateNotification(context)
|
||||
}
|
||||
|
||||
return deletedMessageId
|
||||
}
|
||||
|
||||
fun handleMessageRequestResponse(message: MessageRequestResponse) {
|
||||
@@ -343,6 +352,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
|
||||
}
|
||||
// Parse quote if needed
|
||||
var quoteModel: QuoteModel? = null
|
||||
var quoteMessageBody: String? = null
|
||||
if (message.quote != null && proto.dataMessage.hasQuote()) {
|
||||
val quote = proto.dataMessage.quote
|
||||
|
||||
@@ -354,6 +364,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
|
||||
|
||||
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
|
||||
val messageInfo = messageDataProvider.getMessageForQuote(quote.id, author)
|
||||
quoteMessageBody = messageInfo?.third
|
||||
quoteModel = if (messageInfo != null) {
|
||||
val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList()
|
||||
QuoteModel(quote.id, author,null,false, attachments)
|
||||
@@ -400,6 +411,20 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage,
|
||||
storage.removeReaction(reaction.emoji!!, reaction.timestamp!!, reaction.publicKey!!, threadIsGroup)
|
||||
}
|
||||
} ?: run {
|
||||
// A user is mentioned if their public key is in the body of a message or one of their messages
|
||||
// was quoted
|
||||
val messageText = message.text
|
||||
message.hasMention = listOf(userPublicKey, userBlindedKey)
|
||||
.filterNotNull()
|
||||
.any { key ->
|
||||
return@any (
|
||||
messageText != null &&
|
||||
messageText.contains("@$key")
|
||||
) || (
|
||||
(quoteModel?.author?.serialize() ?: "") == key
|
||||
)
|
||||
}
|
||||
|
||||
// Persist the message
|
||||
message.threadID = threadID
|
||||
val messageID =
|
||||
|
@@ -54,10 +54,9 @@ class ClosedGroupPollerV2 {
|
||||
setUpPolling(groupPublicKey)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
|
||||
allGroupPublicKeys.iterator().forEach { stopPolling(it) }
|
||||
fun stopAll() {
|
||||
futures.forEach { it.value.cancel(false) }
|
||||
isPolling.forEach { isPolling[it.key] = false }
|
||||
}
|
||||
|
||||
fun stopPolling(groupPublicKey: String) {
|
||||
@@ -80,7 +79,12 @@ class ClosedGroupPollerV2 {
|
||||
// reasonable fake time interval to use instead.
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
||||
val threadID = storage.getThreadId(groupID) ?: return
|
||||
val threadID = storage.getThreadId(groupID)
|
||||
if (threadID == null) {
|
||||
Log.d("Loki", "Stopping group poller due to missing thread for closed group: $groupPublicKey.")
|
||||
stopPolling(groupPublicKey)
|
||||
return
|
||||
}
|
||||
val lastUpdated = storage.getLastUpdated(threadID)
|
||||
val timeSinceLastMessage = if (lastUpdated != -1L) Date().time - lastUpdated else 5 * 60 * 1000
|
||||
val minPollInterval = Companion.minPollInterval
|
||||
|
@@ -30,6 +30,7 @@ import org.session.libsignal.protos.SignalServiceProtos
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.successBackground
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -39,15 +40,101 @@ class OpenGroupPoller(private val server: String, private val executorService: S
|
||||
var isCaughtUp = false
|
||||
var secondToLastJob: MessageReceiveJob? = null
|
||||
private var future: ScheduledFuture<*>? = null
|
||||
@Volatile private var runId: UUID = UUID.randomUUID()
|
||||
|
||||
companion object {
|
||||
private const val pollInterval: Long = 4000L
|
||||
const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000
|
||||
|
||||
public fun handleRoomPollInfo(
|
||||
server: String,
|
||||
roomToken: String,
|
||||
pollInfo: OpenGroupApi.RoomPollInfo,
|
||||
createGroupIfMissingWithPublicKey: String? = null
|
||||
) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val groupId = "$server.$roomToken"
|
||||
val dbGroupId = GroupUtil.getEncodedOpenGroupID(groupId.toByteArray())
|
||||
val existingOpenGroup = storage.getOpenGroup(roomToken, server)
|
||||
|
||||
// If we don't have an existing group and don't have a 'createGroupIfMissingWithPublicKey'
|
||||
// value then don't process the poll info
|
||||
val publicKey = existingOpenGroup?.publicKey ?: createGroupIfMissingWithPublicKey
|
||||
val name = pollInfo.details?.name ?: existingOpenGroup?.name
|
||||
val infoUpdates = pollInfo.details?.infoUpdates ?: existingOpenGroup?.infoUpdates
|
||||
|
||||
if (publicKey == null) return
|
||||
|
||||
val openGroup = OpenGroup(
|
||||
server = server,
|
||||
room = pollInfo.token,
|
||||
name = name ?: "",
|
||||
publicKey = publicKey,
|
||||
imageId = (pollInfo.details?.imageId ?: existingOpenGroup?.imageId),
|
||||
canWrite = pollInfo.write,
|
||||
infoUpdates = infoUpdates ?: 0
|
||||
)
|
||||
// - Open Group changes
|
||||
storage.updateOpenGroup(openGroup)
|
||||
|
||||
// - User Count
|
||||
storage.setUserCount(roomToken, server, pollInfo.activeUsers)
|
||||
|
||||
// - Moderators
|
||||
pollInfo.details?.moderators?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.MODERATOR)
|
||||
})
|
||||
}
|
||||
pollInfo.details?.hiddenModerators?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.HIDDEN_MODERATOR)
|
||||
})
|
||||
}
|
||||
// - Admins
|
||||
pollInfo.details?.admins?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.ADMIN)
|
||||
})
|
||||
}
|
||||
pollInfo.details?.hiddenAdmins?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN)
|
||||
})
|
||||
}
|
||||
|
||||
// Update the group avatar
|
||||
if (
|
||||
(
|
||||
pollInfo.details != null &&
|
||||
pollInfo.details.imageId != null && (
|
||||
pollInfo.details.imageId != existingOpenGroup?.imageId ||
|
||||
!storage.hasDownloadedProfilePicture(dbGroupId)
|
||||
) &&
|
||||
storage.getGroupAvatarDownloadJob(openGroup.server, openGroup.room, pollInfo.details.imageId) == null
|
||||
) || (
|
||||
pollInfo.details == null &&
|
||||
existingOpenGroup?.imageId != null &&
|
||||
!storage.hasDownloadedProfilePicture(dbGroupId) &&
|
||||
storage.getGroupAvatarDownloadJob(openGroup.server, openGroup.room, existingOpenGroup.imageId) == null
|
||||
)
|
||||
) {
|
||||
JobQueue.shared.add(GroupAvatarDownloadJob(server, roomToken, openGroup.imageId))
|
||||
}
|
||||
else if (
|
||||
pollInfo.details != null &&
|
||||
pollInfo.details.imageId == null &&
|
||||
existingOpenGroup?.imageId != null
|
||||
) {
|
||||
storage.removeProfilePicture(dbGroupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startIfNeeded() {
|
||||
if (hasStarted) { return }
|
||||
hasStarted = true
|
||||
runId = UUID.randomUUID()
|
||||
future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
@@ -57,9 +144,10 @@ class OpenGroupPoller(private val server: String, private val executorService: S
|
||||
}
|
||||
|
||||
fun poll(isPostCapabilitiesRetry: Boolean = false): Promise<Unit, Exception> {
|
||||
val currentRunId = runId
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room }
|
||||
rooms.forEach { downloadGroupAvatarIfNeeded(it) }
|
||||
|
||||
return OpenGroupApi.poll(rooms, server).successBackground { responses ->
|
||||
responses.filterNot { it.body == null }.forEach { response ->
|
||||
when (response.endpoint) {
|
||||
@@ -86,22 +174,30 @@ class OpenGroupPoller(private val server: String, private val executorService: S
|
||||
isCaughtUp = true
|
||||
}
|
||||
}
|
||||
executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
|
||||
|
||||
// Only poll again if it's the same poller run
|
||||
if (currentRunId == runId) {
|
||||
future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}.fail {
|
||||
updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, it)
|
||||
updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, currentRunId, it)
|
||||
}.map { }
|
||||
}
|
||||
|
||||
private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) {
|
||||
private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, currentRunId: UUID, exception: Exception) {
|
||||
if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) {
|
||||
if (!isPostCapabilitiesRetry) {
|
||||
OpenGroupApi.getCapabilities(server).map {
|
||||
handleCapabilities(server, it)
|
||||
}
|
||||
executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS)
|
||||
|
||||
// Only poll again if it's the same poller run
|
||||
if (currentRunId == runId) {
|
||||
future = executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
|
||||
} else if (currentRunId == runId) {
|
||||
future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,53 +206,6 @@ class OpenGroupPoller(private val server: String, private val executorService: S
|
||||
storage.setServerCapabilities(server, capabilities.capabilities)
|
||||
}
|
||||
|
||||
private fun handleRoomPollInfo(
|
||||
server: String,
|
||||
roomToken: String,
|
||||
pollInfo: OpenGroupApi.RoomPollInfo
|
||||
) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val groupId = "$server.$roomToken"
|
||||
|
||||
val existingOpenGroup = storage.getOpenGroup(roomToken, server)
|
||||
val publicKey = existingOpenGroup?.publicKey ?: return
|
||||
val openGroup = OpenGroup(
|
||||
server = server,
|
||||
room = pollInfo.token,
|
||||
name = pollInfo.details?.name ?: "",
|
||||
infoUpdates = pollInfo.details?.infoUpdates ?: 0,
|
||||
publicKey = publicKey,
|
||||
)
|
||||
// - Open Group changes
|
||||
storage.updateOpenGroup(openGroup)
|
||||
|
||||
// - User Count
|
||||
storage.setUserCount(roomToken, server, pollInfo.activeUsers)
|
||||
|
||||
// - Moderators
|
||||
pollInfo.details?.moderators?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.MODERATOR)
|
||||
})
|
||||
}
|
||||
pollInfo.details?.hiddenModerators?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.HIDDEN_MODERATOR)
|
||||
})
|
||||
}
|
||||
// - Admins
|
||||
pollInfo.details?.admins?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.ADMIN)
|
||||
})
|
||||
}
|
||||
pollInfo.details?.hiddenAdmins?.let { moderatorList ->
|
||||
storage.setGroupMemberRoles(moderatorList.map {
|
||||
GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessages(
|
||||
server: String,
|
||||
roomToken: String,
|
||||
@@ -284,16 +333,4 @@ class OpenGroupPoller(private val server: String, private val executorService: S
|
||||
JobQueue.shared.add(deleteJob)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadGroupAvatarIfNeeded(room: String) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
if (storage.getGroupAvatarDownloadJob(server, room) != null) return
|
||||
val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray())
|
||||
storage.getGroup(groupId)?.let {
|
||||
if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) {
|
||||
JobQueue.shared.add(GroupAvatarDownloadJob(room, server))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -10,16 +10,17 @@ import org.session.libsession.messaging.sending_receiving.data_extraction.DataEx
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType
|
||||
import org.session.libsession.utilities.truncateIdForDisplay
|
||||
|
||||
object UpdateMessageBuilder {
|
||||
|
||||
fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, sender: String? = null, isOutgoing: Boolean = false): String {
|
||||
fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, senderId: String? = null, isOutgoing: Boolean = false): String {
|
||||
var message = ""
|
||||
val updateData = updateMessageData.kind ?: return message
|
||||
if (!isOutgoing && sender == null) return message
|
||||
if (!isOutgoing && senderId == null) return message
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val senderName: String = if (!isOutgoing) {
|
||||
storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender
|
||||
storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId)
|
||||
} else { context.getString(R.string.MessageRecord_you) }
|
||||
|
||||
when (updateData) {
|
||||
@@ -80,11 +81,11 @@ object UpdateMessageBuilder {
|
||||
return message
|
||||
}
|
||||
|
||||
fun buildExpirationTimerMessage(context: Context, duration: Long, sender: String? = null, isOutgoing: Boolean = false): String {
|
||||
if (!isOutgoing && sender == null) return ""
|
||||
fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String {
|
||||
if (!isOutgoing && senderId == null) return ""
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val senderName: String? = if (!isOutgoing) {
|
||||
storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender
|
||||
storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId)
|
||||
} else { context.getString(R.string.MessageRecord_you) }
|
||||
return if (duration <= 0) {
|
||||
if (isOutgoing) {
|
||||
@@ -124,9 +125,9 @@ object UpdateMessageBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, sender: String? = null): String {
|
||||
fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null): String {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val senderName = storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender!!
|
||||
val senderName = storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId)
|
||||
return when (kind) {
|
||||
DataExtractionNotificationInfoMessage.Kind.SCREENSHOT ->
|
||||
context.getString(R.string.MessageRecord_s_took_a_screenshot, senderName)
|
||||
|
@@ -25,6 +25,8 @@ import org.session.libsignal.utilities.Snode
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.session.libsignal.utilities.recover
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.collections.set
|
||||
|
||||
private typealias Path = List<Snode>
|
||||
@@ -42,13 +44,27 @@ object OnionRequestAPI {
|
||||
private val snodeFailureCount = mutableMapOf<Snode, Int>()
|
||||
|
||||
var guardSnodes = setOf<Snode>()
|
||||
var _paths: AtomicReference<List<Path>?> = AtomicReference(null)
|
||||
var paths: List<Path> // Not a set to ensure we consistently show the same path to the user
|
||||
get() = database.getOnionRequestPaths()
|
||||
get() {
|
||||
val paths = _paths.get()
|
||||
|
||||
if (paths != null) { return paths }
|
||||
|
||||
// Storing this in an atomic variable as it was causing a number of background
|
||||
// ANRs when this value was accessed via the main thread after tapping on
|
||||
// a notification)
|
||||
val result = database.getOnionRequestPaths()
|
||||
_paths.set(result)
|
||||
return result
|
||||
}
|
||||
set(newValue) {
|
||||
if (newValue.isEmpty()) {
|
||||
database.clearOnionRequestPaths()
|
||||
_paths.set(null)
|
||||
} else {
|
||||
database.setOnionRequestPaths(newValue)
|
||||
_paths.set(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +93,8 @@ object OnionRequestAPI {
|
||||
// endregion
|
||||
|
||||
class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination)
|
||||
open class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String)
|
||||
: Exception("HTTP request failed at destination ($destination) with status code $statusCode.")
|
||||
open class HTTPRequestFailedAtDestinationException(statusCode: Int, json: Map<*, *>, val destination: String)
|
||||
: HTTP.HTTPRequestFailedException(statusCode, json, "HTTP request failed at destination ($destination) with status code $statusCode.")
|
||||
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
|
||||
|
||||
private data class OnionBuildingResult(
|
||||
|
@@ -55,9 +55,10 @@ object SnodeAPI {
|
||||
* The offset between the user's clock and the Service Node's clock. Used in cases where the
|
||||
* user's clock is incorrect.
|
||||
*/
|
||||
var clockOffset = 0L
|
||||
internal var clockOffset = 0L
|
||||
|
||||
val nowWithClockOffset
|
||||
@JvmStatic
|
||||
public val nowWithOffset
|
||||
get() = System.currentTimeMillis() + clockOffset
|
||||
|
||||
internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue ->
|
||||
@@ -72,12 +73,16 @@ object SnodeAPI {
|
||||
private val minimumSnodePoolCount = 12
|
||||
private val minimumSwarmSnodeCount = 3
|
||||
// Use port 4433 if the API level can handle the network security configuration and enforce pinned certificates
|
||||
private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433
|
||||
private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4443
|
||||
private val seedNodePool by lazy {
|
||||
if (useTestnet) {
|
||||
setOf( "http://public.loki.foundation:38157" )
|
||||
} else {
|
||||
setOf( "https://storage.seed1.loki.network:$seedNodePort", "https://storage.seed3.loki.network:$seedNodePort", "https://public.loki.foundation:$seedNodePort" )
|
||||
setOf(
|
||||
"https://seed1.getsession.org:$seedNodePort",
|
||||
"https://seed2.getsession.org:$seedNodePort",
|
||||
"https://seed3.getsession.org:$seedNodePort",
|
||||
)
|
||||
}
|
||||
}
|
||||
private const val snodeFailureThreshold = 3
|
||||
@@ -375,7 +380,7 @@ object SnodeAPI {
|
||||
val parameters = message.toJSON().toMutableMap<String,Any>()
|
||||
// Construct signature
|
||||
if (requiresAuth) {
|
||||
val sigTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset
|
||||
val sigTimestamp = nowWithOffset
|
||||
val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString
|
||||
val signature = ByteArray(Sign.BYTES)
|
||||
// assume namespace here is non-zero, as zero namespace doesn't require auth
|
||||
|
@@ -20,6 +20,7 @@ data class SnodeMessage(
|
||||
*/
|
||||
val timestamp: Long
|
||||
) {
|
||||
internal constructor(): this("", "", -1, -1)
|
||||
|
||||
internal fun toJSON(): Map<String, String> {
|
||||
return mapOf(
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.session.libsession.utilities
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK
|
||||
import org.session.libsignal.utilities.ByteUtil
|
||||
import org.session.libsignal.utilities.Util
|
||||
import org.session.libsignal.utilities.Hex
|
||||
@@ -27,9 +28,11 @@ internal object AESGCM {
|
||||
internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||
val iv = ivAndCiphertext.sliceArray(0 until ivSize)
|
||||
val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count())
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return cipher.doFinal(ciphertext)
|
||||
synchronized(CIPHER_LOCK) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,9 +50,11 @@ internal object AESGCM {
|
||||
*/
|
||||
internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||
val iv = Util.getSecretBytes(ivSize)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return ByteUtil.combine(iv, cipher.doFinal(plaintext))
|
||||
synchronized(CIPHER_LOCK) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return ByteUtil.combine(iv, cipher.doFinal(plaintext))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -2,6 +2,7 @@ package org.session.libsession.utilities
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import org.session.libsession.messaging.file_server.FileServerApi
|
||||
import org.session.libsignal.utilities.HTTP
|
||||
import org.session.libsignal.utilities.Log
|
||||
import java.io.*
|
||||
|
||||
@@ -40,7 +41,11 @@ object DownloadUtilities {
|
||||
outputStream.write(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Loki", "Couldn't download attachment.", e)
|
||||
when (e) {
|
||||
// No need for the stack trace for HTTP errors
|
||||
is HTTP.HTTPRequestFailedException -> Log.e("Loki", "Couldn't download attachment due to error: ${e.message}")
|
||||
else -> Log.e("Loki", "Couldn't download attachment", e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,4 @@
|
||||
package org.session.libsession.utilities
|
||||
|
||||
fun truncateIdForDisplay(id: String): String =
|
||||
id.takeIf { it.length > 8 }?.apply{ "${take(4)}…${takeLast(4)}" } ?: id
|
@@ -25,7 +25,7 @@ class SSKEnvironment(
|
||||
|
||||
interface ProfileManagerProtocol {
|
||||
companion object {
|
||||
const val NAME_PADDED_LENGTH = 26
|
||||
const val NAME_PADDED_LENGTH = 64
|
||||
}
|
||||
|
||||
fun setNickname(context: Context, recipient: Recipient, nickname: String?)
|
||||
@@ -52,4 +52,4 @@ class SSKEnvironment(
|
||||
shared = SSKEnvironment(typingIndicators, readReceiptManager, profileManager, notificationManager, messageExpirationManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -649,7 +649,7 @@ interface TextSecurePreferences {
|
||||
|
||||
@JvmStatic
|
||||
fun isScreenSecurityEnabled(context: Context): Boolean {
|
||||
return getBooleanPreference(context, SCREEN_SECURITY_PREF, !BuildConfig.DEBUG)
|
||||
return getBooleanPreference(context, SCREEN_SECURITY_PREF, context.resources.getBoolean(R.bool.screen_security_default))
|
||||
}
|
||||
|
||||
fun getLastVersionCode(context: Context): Int {
|
||||
|
@@ -13,4 +13,4 @@ fun Context.getColorFromAttr(
|
||||
): Int {
|
||||
theme.resolveAttribute(attrColor, typedValue, resolveRefs)
|
||||
return typedValue.data
|
||||
}
|
||||
}
|
||||
|
54
libsession/src/main/res/values-fr-rFR/strings.xml
Normal file
54
libsession/src/main/res/values-fr-rFR/strings.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- MessageRecord -->
|
||||
<string name="MessageRecord_left_group">Vous avez quitté le groupe.</string>
|
||||
<string name="MessageRecord_you_created_a_new_group">Vous avez créé un nouveau groupe.</string>
|
||||
<string name="MessageRecord_s_added_you_to_the_group">%1$s vous a ajouté·e dans le groupe.</string>
|
||||
<string name="MessageRecord_you_renamed_the_group_to_s">Vous avez renommé le groupe en %1$s</string>
|
||||
<string name="MessageRecord_s_renamed_the_group_to_s">%1$s a renommé le groupe en : %2$s</string>
|
||||
<string name="MessageRecord_you_added_s_to_the_group">Vous avez ajouté %1$s au groupe.</string>
|
||||
<string name="MessageRecord_s_added_s_to_the_group">%1$s a ajouté %2$s au groupe.</string>
|
||||
<string name="MessageRecord_you_removed_s_from_the_group">Vous avez retiré %1$s du groupe.</string>
|
||||
<string name="MessageRecord_s_removed_s_from_the_group">%1$s a supprimé %2$s du groupe.</string>
|
||||
<string name="MessageRecord_you_were_removed_from_the_group">Vous avez été retiré·e du groupe.</string>
|
||||
<string name="MessageRecord_you">Vous</string>
|
||||
<string name="MessageRecord_s_called_you">%s vous a appelé·e</string>
|
||||
<string name="MessageRecord_called_s">Vous avez appelé %s</string>
|
||||
<string name="MessageRecord_missed_call_from">Appel manqué de %s</string>
|
||||
<string name="MessageRecord_you_disabled_disappearing_messages">Vous avez désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s a désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez défini l’expiration des messages éphémères à %1$s</string>
|
||||
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a défini l’expiration des messages éphémères à %2$s</string>
|
||||
<string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'écran.</string>
|
||||
<string name="MessageRecord_media_saved_by_s">%1$s a enregistré le média.</string>
|
||||
<!-- expiration -->
|
||||
<string name="expiration_off">Désactivé</string>
|
||||
<plurals name="expiration_seconds">
|
||||
<item quantity="one">%d seconde</item>
|
||||
<item quantity="other">%d secondes</item>
|
||||
</plurals>
|
||||
<string name="expiration_seconds_abbreviated">%d s</string>
|
||||
<plurals name="expiration_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<string name="expiration_minutes_abbreviated">%d min</string>
|
||||
<plurals name="expiration_hours">
|
||||
<item quantity="one">%d heure</item>
|
||||
<item quantity="other">%d heures</item>
|
||||
</plurals>
|
||||
<string name="expiration_hours_abbreviated">%d h</string>
|
||||
<plurals name="expiration_days">
|
||||
<item quantity="one">%d jour</item>
|
||||
<item quantity="other">%d jours</item>
|
||||
</plurals>
|
||||
<string name="expiration_days_abbreviated">%d j</string>
|
||||
<plurals name="expiration_weeks">
|
||||
<item quantity="one">%d semaine</item>
|
||||
<item quantity="other">%d semaines</item>
|
||||
</plurals>
|
||||
<string name="expiration_weeks_abbreviated">%d sem</string>
|
||||
<string name="ConversationItem_group_action_left">%1$s a quitté le groupe.</string>
|
||||
<!-- RecipientProvider -->
|
||||
<string name="RecipientProvider_unnamed_group">Groupe sans nom</string>
|
||||
</resources>
|
54
libsession/src/main/res/values-fr/strings.xml
Normal file
54
libsession/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- MessageRecord -->
|
||||
<string name="MessageRecord_left_group">Vous avez quitté le groupe.</string>
|
||||
<string name="MessageRecord_you_created_a_new_group">Vous avez créé un nouveau groupe.</string>
|
||||
<string name="MessageRecord_s_added_you_to_the_group">%1$s vous a ajouté·e dans le groupe.</string>
|
||||
<string name="MessageRecord_you_renamed_the_group_to_s">Vous avez renommé le groupe en %1$s</string>
|
||||
<string name="MessageRecord_s_renamed_the_group_to_s">%1$s a renommé le groupe en : %2$s</string>
|
||||
<string name="MessageRecord_you_added_s_to_the_group">Vous avez ajouté %1$s au groupe.</string>
|
||||
<string name="MessageRecord_s_added_s_to_the_group">%1$s a ajouté %2$s au groupe.</string>
|
||||
<string name="MessageRecord_you_removed_s_from_the_group">Vous avez retiré %1$s du groupe.</string>
|
||||
<string name="MessageRecord_s_removed_s_from_the_group">%1$s a supprimé %2$s du groupe.</string>
|
||||
<string name="MessageRecord_you_were_removed_from_the_group">Vous avez été retiré·e du groupe.</string>
|
||||
<string name="MessageRecord_you">Vous</string>
|
||||
<string name="MessageRecord_s_called_you">%s vous a appelé·e</string>
|
||||
<string name="MessageRecord_called_s">Vous avez appelé %s</string>
|
||||
<string name="MessageRecord_missed_call_from">Appel manqué de %s</string>
|
||||
<string name="MessageRecord_you_disabled_disappearing_messages">Vous avez désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s a désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez défini l’expiration des messages éphémères à %1$s</string>
|
||||
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a défini l’expiration des messages éphémères à %2$s</string>
|
||||
<string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'écran.</string>
|
||||
<string name="MessageRecord_media_saved_by_s">%1$s a enregistré le média.</string>
|
||||
<!-- expiration -->
|
||||
<string name="expiration_off">Désactivé</string>
|
||||
<plurals name="expiration_seconds">
|
||||
<item quantity="one">%d seconde</item>
|
||||
<item quantity="other">%d secondes</item>
|
||||
</plurals>
|
||||
<string name="expiration_seconds_abbreviated">%d s</string>
|
||||
<plurals name="expiration_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<string name="expiration_minutes_abbreviated">%d min</string>
|
||||
<plurals name="expiration_hours">
|
||||
<item quantity="one">%d heure</item>
|
||||
<item quantity="other">%d heures</item>
|
||||
</plurals>
|
||||
<string name="expiration_hours_abbreviated">%d h</string>
|
||||
<plurals name="expiration_days">
|
||||
<item quantity="one">%d jour</item>
|
||||
<item quantity="other">%d jours</item>
|
||||
</plurals>
|
||||
<string name="expiration_days_abbreviated">%d j</string>
|
||||
<plurals name="expiration_weeks">
|
||||
<item quantity="one">%d semaine</item>
|
||||
<item quantity="other">%d semaines</item>
|
||||
</plurals>
|
||||
<string name="expiration_weeks_abbreviated">%d sem</string>
|
||||
<string name="ConversationItem_group_action_left">%1$s a quitté le groupe.</string>
|
||||
<!-- RecipientProvider -->
|
||||
<string name="RecipientProvider_unnamed_group">Groupe sans nom</string>
|
||||
</resources>
|
@@ -2,4 +2,5 @@
|
||||
<resources>
|
||||
<bool name="enable_alarm_manager">true</bool>
|
||||
<bool name="enable_job_service">false</bool>
|
||||
<bool name="screen_security_default">true</bool>
|
||||
</resources>
|
||||
|
Reference in New Issue
Block a user