Merge branch 'dev' of https://github.com/loki-project/session-android into zombie-handling-update

This commit is contained in:
Brice-W
2021-05-25 16:15:51 +10:00
197 changed files with 3144 additions and 4302 deletions

View File

@@ -28,7 +28,7 @@ dependencies {
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
implementation 'com.annimon:stream:1.1.8'
implementation 'com.makeramen:roundedimageview:2.1.0'
implementation 'com.esotericsoftware:kryo:4.0.1'
implementation 'com.esotericsoftware:kryo:5.1.1'
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "org.whispersystems:curve25519-java:$curve25519Version"

View File

@@ -1,9 +1,8 @@
package org.session.libsession.database
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.sending_receiving.attachments.*
import org.session.libsession.utilities.Address
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.utilities.UploadResult
import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceAttachmentStream
import java.io.InputStream
@@ -13,31 +12,20 @@ interface MessageDataProvider {
fun getMessageID(serverID: Long): Long?
fun getMessageID(serverId: Long, threadId: Long): Pair<Long, Boolean>?
fun deleteMessage(messageID: Long, isSms: Boolean)
fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment?
fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream?
fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer?
fun getSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream?
fun getScaledSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream?
fun getSignalAttachmentPointer(attachmentId: Long): SignalServiceAttachmentPointer?
fun setAttachmentState(attachmentState: AttachmentState, attachmentId: Long, messageID: Long)
fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream)
fun isOutgoingMessage(timestamp: Long): Boolean
fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: DotNetAPI.UploadResult)
fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult)
fun handleFailedAttachmentUpload(attachmentId: Long)
fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>?
fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List<Attachment>
fun getMessageBodyFor(timestamp: Long, author: String): String
fun getAttachmentIDsFor(messageID: Long): List<Long>
fun getLinkPreviewAttachmentIDFor(messageID: Long): Long?
fun getOpenGroup(threadID: Long): OpenGroup?
}

View File

@@ -1,15 +1,15 @@
package org.session.libsession.database
import android.content.Context
import android.net.Uri
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupV2
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
@@ -18,6 +18,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.RecipientSettings
import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer
@@ -32,14 +33,8 @@ interface StorageProtocol {
fun getUserDisplayName(): String?
fun getUserProfileKey(): ByteArray?
fun getUserProfilePictureURL(): String?
fun setUserProfilePictureUrl(newProfilePicture: String)
fun getProfileKeyForRecipient(recipientPublicKey: String): ByteArray?
fun getDisplayNameForRecipient(recipientPublicKey: String): String?
fun setProfileKeyForRecipient(recipientPublicKey: String, profileKey: ByteArray)
// Signal Protocol
fun setUserProfilePictureURL(newProfilePicture: String)
// Signal
fun getOrGenerateRegistrationID(): Int
// Jobs
@@ -49,6 +44,7 @@ interface StorageProtocol {
fun getAllPendingJobs(type: String): Map<String,Job?>
fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob?
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
fun getMessageReceiveJob(messageReceiveJobID: String): MessageReceiveJob?
fun resumeMessageSendJobIfNeeded(messageSendJobID: String)
fun isJobCanceled(job: Job): Boolean
@@ -59,48 +55,40 @@ interface StorageProtocol {
// Open Groups
fun getAllV2OpenGroups(): Map<Long, OpenGroupV2>
fun getV2OpenGroup(threadId: String): OpenGroupV2?
// Open Groups
fun getThreadID(openGroupID: String): String?
fun addOpenGroup(serverUrl: String, channel: Long)
fun getV2OpenGroup(threadId: Long): OpenGroupV2?
fun addOpenGroup(urlAsString: String)
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
fun getQuoteServerID(quoteID: Long, publicKey: String): Long?
// Open Group Public Keys
fun getOpenGroupPublicKey(server: String): String?
fun setOpenGroupPublicKey(server: String, newValue: String)
// Open Group User Info
fun setOpenGroupDisplayName(publicKey: String, room: String, server: String, displayName: String)
fun getOpenGroupDisplayName(publicKey: String, room: String, server: String): String?
// Open Group Metadata
fun updateTitle(groupID: String, newValue: String)
fun updateProfilePicture(groupID: String, newValue: ByteArray)
fun setUserCount(room: String, server: String, newValue: Int)
// Last Message Server ID
fun getLastMessageServerId(room: String, server: String): Long?
fun setLastMessageServerId(room: String, server: String, newValue: Long)
fun removeLastMessageServerId(room: String, server: String)
fun getLastMessageServerID(room: String, server: String): Long?
fun setLastMessageServerID(room: String, server: String, newValue: Long)
fun removeLastMessageServerID(room: String, server: String)
// Last Deletion Server ID
fun getLastDeletionServerId(room: String, server: String): Long?
fun setLastDeletionServerId(room: String, server: String, newValue: Long)
fun removeLastDeletionServerId(room: String, server: String)
fun getLastDeletionServerID(room: String, server: String): Long?
fun setLastDeletionServerID(room: String, server: String, newValue: Long)
fun removeLastDeletionServerID(room: String, server: String)
// Message Handling
fun isDuplicateMessage(timestamp: Long): Boolean
fun getReceivedMessageTimestamps(): Set<Long>
fun addReceivedMessageTimestamp(timestamp: Long)
fun removeReceivedMessageTimestamps(timestamps: Set<Long>)
// Returns the IDs of the saved attachments.
fun persistAttachments(messageId: Long, attachments: List<Attachment>): List<Long>
fun getAttachmentsForMessage(messageId: Long): List<DatabaseAttachment>
fun getMessageIdInDatabase(timestamp: Long, author: String): Long?
/**
* Returns the IDs of the saved attachments.
*/
fun persistAttachments(messageID: Long, attachments: List<Attachment>): List<Long>
fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment>
fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name
fun markAsSent(timestamp: Long, author: String)
fun markUnidentified(timestamp: Long, author: String)
fun setErrorMessage(timestamp: Long, author: String, error: Exception)
@@ -110,11 +98,10 @@ interface StorageProtocol {
fun createGroup(groupID: String, title: String?, members: List<Address>, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List<Address>, formationTimestamp: Long)
fun isGroupActive(groupPublicKey: String): Boolean
fun setActive(groupID: String, value: Boolean)
fun getZombieMember(groupID: String): Set<String>
fun getZombieMembers(groupID: String): Set<String>
fun removeMember(groupID: String, member: Address)
fun updateMembers(groupID: String, members: List<Address>)
fun updateZombieMembers(groupID: String, members: List<Address>)
// Closed Group
fun setZombieMembers(groupID: String, members: List<Address>)
fun getAllClosedGroupPublicKeys(): Set<String>
fun getAllActiveClosedGroupPublicKeys(): Set<String>
fun addClosedGroupPublicKey(groupPublicKey: String)
@@ -122,9 +109,9 @@ interface StorageProtocol {
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String)
fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String)
fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type,
name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long)
name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long)
fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String,
members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long)
members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long)
fun isClosedGroup(publicKey: String): Boolean
fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList<ECKeyPair>
fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair?
@@ -138,57 +125,27 @@ interface StorageProtocol {
// Thread
fun getOrCreateThreadIdFor(address: Address): Long
fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long
fun getThreadIdFor(address: Address): Long?
fun getThreadId(publicKeyOrOpenGroupID: String): Long?
fun getThreadId(address: Address): Long?
fun getThreadId(recipient: Recipient): Long?
fun getThreadIdForMms(mmsId: Long): Long
fun getLastUpdated(threadID: Long): Long
// Session Request
fun getSessionRequestSentTimestamp(publicKey: String): Long?
fun setSessionRequestSentTimestamp(publicKey: String, newValue: Long)
fun getSessionRequestProcessedTimestamp(publicKey: String): Long?
fun setSessionRequestProcessedTimestamp(publicKey: String, newValue: Long)
// Loki User
fun getDisplayName(publicKey: String): String?
fun setDisplayName(publicKey: String, newName: String)
fun getServerDisplayName(serverID: String, publicKey: String): String?
fun getProfilePictureURL(publicKey: String): String?
// Recipient
// Contacts
fun getContactWithSessionID(sessionID: String): Contact?
fun getAllContacts(): Set<Contact>
fun setContact(contact: Contact)
fun getRecipientSettings(address: Address): RecipientSettings?
fun addContacts(contacts: List<ConfigurationMessage.Contact>)
// PartAuthority
// Attachments
fun getAttachmentDataUri(attachmentId: AttachmentId): Uri
fun getAttachmentThumbnailUri(attachmentId: AttachmentId): Uri
// Message Handling
/// Returns the ID of the `TSIncomingMessage` that was constructed.
/**
* Returns the ID of the `TSIncomingMessage` that was constructed.
*/
fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>): Long?
// Data Extraction Notification
fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long)
// DEPRECATED
fun getAuthToken(server: String): String?
fun setAuthToken(server: String, newValue: String?)
fun removeAuthToken(server: String)
fun getLastMessageServerID(group: Long, server: String): Long?
fun setLastMessageServerID(group: Long, server: String, newValue: Long)
fun removeLastMessageServerID(group: Long, server: String)
fun getLastDeletionServerID(group: Long, server: String): Long?
fun setLastDeletionServerID(group: Long, server: String, newValue: Long)
fun removeLastDeletionServerID(group: Long, server: String)
fun getOpenGroup(threadID: String): OpenGroup?
fun getAllOpenGroups(): Map<Long, OpenGroup>
fun setUserCount(group: Long, server: String, newValue: Int)
fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String)
fun getOpenGroupProfilePictureURL(group: Long, server: String): String?
fun setOpenGroupDisplayName(publicKey: String, channel: Long, server: String, displayName: String)
fun getOpenGroupDisplayName(publicKey: String, channel: Long, server: String): String?
}

View File

@@ -5,17 +5,17 @@ import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
class MessagingModuleConfiguration(
val context: Context,
val storage: StorageProtocol,
val messageDataProvider: MessageDataProvider
val context: Context,
val storage: StorageProtocol,
val messageDataProvider: MessageDataProvider
) {
companion object {
lateinit var shared: MessagingModuleConfiguration
fun configure(context: Context,
storage: StorageProtocol,
messageDataProvider: MessageDataProvider
storage: StorageProtocol,
messageDataProvider: MessageDataProvider
) {
if (Companion::shared.isInitialized) { return }
shared = MessagingModuleConfiguration(context, storage, messageDataProvider)

View File

@@ -0,0 +1,87 @@
package org.session.libsession.messaging.contacts
import org.session.libsession.utilities.recipients.Recipient
class Contact(val sessionID: String) {
/**
* The URL from which to fetch the contact's profile picture.
*/
var profilePictureURL: String? = null
/**
* The file name of the contact's profile picture on local storage.
*/
var profilePictureFileName: String? = null
/**
* The key with which the profile picture is encrypted.
*/
var profilePictureEncryptionKey: ByteArray? = null
/**
* The ID of the thread associated with this contact.
*/
var threadID: Long? = null
/**
* This flag is used to determine whether we should auto-download files sent by this contact.
*/
var isTrusted = false
// region Name
/**
* The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
*/
var name: String? = null
/**
* The contact's nickname, if the user set one.
*/
var nickname: String? = null
/**
* The name to display in the UI. For local use only.
*/
fun displayName(context: ContactContext): String? {
nickname?.let { return it }
return when (context) {
ContactContext.REGULAR -> name
ContactContext.OPEN_GROUP -> {
// In open groups, where it's more likely that multiple users have the same name,
// we display a bit of the Session ID after a user's display name for added context.
name?.let {
return "$name (...${sessionID.takeLast(8)})"
}
return null
}
}
}
// endregion
enum class ContactContext {
REGULAR, OPEN_GROUP
}
fun isValid(): Boolean {
if (profilePictureURL != null) { return profilePictureEncryptionKey != null }
if (profilePictureEncryptionKey != null) { return profilePictureURL != null}
return true
}
override fun equals(other: Any?): Boolean {
return this.sessionID == (other as? Contact)?.sessionID
}
override fun hashCode(): Int {
return sessionID.hashCode()
}
override fun toString(): String {
return nickname ?: name ?: sessionID
}
companion object {
fun contextForRecipient(recipient: Recipient): ContactContext {
return if (recipient.isOpenGroupRecipient) {
ContactContext.OPEN_GROUP
} else {
ContactContext.REGULAR
}
}
}
}

View File

@@ -1,75 +0,0 @@
package org.session.libsession.messaging.file_server
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.*
import java.net.URL
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : DotNetAPI() {
companion object {
internal val fileServerPublicKey = "62509D59BDEEC404DD0D489C1E15BA8F94FD3D619B01C1BF48A9922BFCB7311C"
internal val maxRetryCount = 4
public 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.
*/
public val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5?
public val fileStorageBucketURL = "https://file-static.lokinet.org"
// endregion
// region Initialization
lateinit var shared: FileServerAPI
/**
* Must be called before `LokiAPI` is used.
*/
fun configure(userPublicKey: String, userPrivateKey: ByteArray, database: LokiAPIDatabaseProtocol) {
if (Companion::shared.isInitialized) { return }
val server = "https://file.getsession.org"
shared = FileServerAPI(server, userPublicKey, userPrivateKey, database)
}
// endregion
}
// region Open Group Server Public Key
fun getPublicKeyForOpenGroupServer(openGroupServer: String): Promise<String, Exception> {
val publicKey = database.getOpenGroupPublicKey(openGroupServer)
if (publicKey != null && PublicKeyValidation.isValid(publicKey, 64, false)) {
return Promise.of(publicKey)
} else {
val url = "$server/loki/v1/getOpenGroupKey/${URL(openGroupServer).host}"
val request = Request.Builder().url(url)
request.addHeader("Content-Type", "application/json")
request.addHeader("Authorization", "Bearer loki") // Tokenless request; use a dummy token
return OnionRequestAPI.sendOnionRequest(request.build(), server, fileServerPublicKey).map { json ->
try {
val bodyAsString = json["data"] as String
val body = JsonUtil.fromJson(bodyAsString)
val base64EncodedPublicKey = body.get("data").asText()
val prefixedPublicKey = Base64.decode(base64EncodedPublicKey)
val hexEncodedPrefixedPublicKey = prefixedPublicKey.toHexString()
val result = hexEncodedPrefixedPublicKey.removing05PrefixIfNeeded()
database.setOpenGroupPublicKey(openGroupServer, result)
result
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse open group public key from: $json.")
throw exception
}
}
}
}
}

View File

@@ -15,10 +15,18 @@ import org.session.libsignal.utilities.Log
object FileServerAPIV2 {
private const val OLD_SERVER_PUBLIC_KEY = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69"
const val OLD_SERVER = "http://88.99.175.227"
private const val SERVER_PUBLIC_KEY = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
const val SERVER = "http://filev2.getsession.org"
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.")
@@ -44,9 +52,7 @@ object FileServerAPIV2 {
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
}
private fun send(request: Request, useOldServer: Boolean): Promise<Map<*, *>, Exception> {
val server = if (useOldServer) OLD_SERVER else SERVER
val serverPublicKey = if (useOldServer) OLD_SERVER_PUBLIC_KEY else SERVER_PUBLIC_KEY
private fun send(request: Request): Promise<Map<*, *>, Exception> {
val url = HttpUrl.parse(server) ?: return Promise.ofFail(OpenGroupAPIV2.Error.InvalidURL)
val urlBuilder = HttpUrl.Builder()
.scheme(url.scheme())
@@ -80,14 +86,14 @@ object FileServerAPIV2 {
val base64EncodedFile = Base64.encodeBytes(file)
val parameters = mapOf( "file" to base64EncodedFile )
val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters)
return send(request, false).map { json ->
return send(request).map { json ->
json["result"] as? Long ?: throw OpenGroupAPIV2.Error.ParsingFailed
}
}
fun download(file: Long, useOldServer: Boolean): Promise<ByteArray, Exception> {
fun download(file: Long): Promise<ByteArray, Exception> {
val request = Request(verb = HTTP.Verb.GET, endpoint = "files/$file")
return send(request, useOldServer).map { json ->
return send(request).map { json ->
val base64EncodedFile = json["result"] as? String ?: throw Error.ParsingFailed
Base64.decode(base64EncodedFile) ?: throw Error.ParsingFailed
}

View File

@@ -2,11 +2,9 @@ package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.utilities.DownloadUtilities
import org.session.libsignal.streams.AttachmentCipherInputStream
import org.session.libsignal.utilities.Base64
@@ -42,11 +40,6 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
if (exception == Error.NoAttachment) {
messageDataProvider.setAttachmentState(AttachmentState.FAILED, attachmentID, databaseMessageID)
this.handlePermanentFailure(exception)
} else if (exception == DotNetAPI.Error.ParsingFailed) {
// No need to retry if the response is invalid. Most likely this means we (incorrectly)
// got a "Cannot GET ..." error from the file server.
messageDataProvider.setAttachmentState(AttachmentState.FAILED, attachmentID, databaseMessageID)
this.handlePermanentFailure(exception)
} else {
this.handleFailure(exception)
}
@@ -57,9 +50,9 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachmentID, this.databaseMessageID)
val tempFile = createTempFile()
val threadID = storage.getThreadIdForMms(databaseMessageID)
val openGroupV2 = storage.getV2OpenGroup(threadID.toString())
val openGroupV2 = storage.getV2OpenGroup(threadID)
val inputStream = if (openGroupV2 == null) {
DownloadUtilities.downloadFile(tempFile, attachment.url, FileServerAPI.maxFileSize, null)
DownloadUtilities.downloadFile(tempFile, attachment.url)
// Assume we're retrieving an attachment for an open group server if the digest is not set
if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) {
FileInputStream(tempFile)

View File

@@ -6,13 +6,12 @@ import com.esotericsoftware.kryo.io.Output
import nl.komponents.kovenant.Promise
import okio.Buffer
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.utilities.UploadResult
import org.session.libsignal.streams.AttachmentCipherOutputStream
import org.session.libsignal.messages.SignalServiceAttachmentStream
import org.session.libsignal.streams.PaddingInputStream
@@ -53,37 +52,28 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID)
?: return handleFailure(Error.NoAttachment)
val v2OpenGroup = storage.getV2OpenGroup(threadID)
val v1OpenGroup = storage.getOpenGroup(threadID)
val v2OpenGroup = storage.getV2OpenGroup(threadID.toLong())
if (v2OpenGroup != null) {
val keyAndResult = upload(attachment, v2OpenGroup.server, false) {
OpenGroupAPIV2.upload(it, v2OpenGroup.room, v2OpenGroup.server)
}
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
} else if (v1OpenGroup == null) {
val keyAndResult = upload(attachment, FileServerAPIV2.SERVER, true) {
} else {
val keyAndResult = upload(attachment, FileServerAPIV2.server, true) {
FileServerAPIV2.upload(it)
}
handleSuccess(attachment, keyAndResult.first, keyAndResult.second)
} else { // V1 open group
val server = v1OpenGroup.server
val pushData = PushAttachmentData(attachment.contentType, attachment.inputStream,
attachment.length, PlaintextOutputStreamFactory(), attachment.listener)
val result = FileServerAPI.shared.uploadAttachment(server, pushData)
handleSuccess(attachment, ByteArray(0), result)
}
} catch (e: java.lang.Exception) {
if (e == Error.NoAttachment) {
this.handlePermanentFailure(e)
} else if (e is DotNetAPI.Error && !e.isRetryable) {
this.handlePermanentFailure(e)
} else {
this.handleFailure(e)
}
}
}
private fun upload(attachment: SignalServiceAttachmentStream, server: String, encrypt: Boolean, upload: (ByteArray) -> Promise<Long, Exception>): Pair<ByteArray, DotNetAPI.UploadResult> {
private fun upload(attachment: SignalServiceAttachmentStream, server: String, encrypt: Boolean, upload: (ByteArray) -> Promise<Long, Exception>): Pair<ByteArray, UploadResult> {
// Key
val key = if (encrypt) Util.getSecretBytes(64) else ByteArray(0)
// Length
@@ -112,10 +102,10 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val id = upload(data).get()
val digest = drb.transmittedDigest
// Return
return Pair(key, DotNetAPI.UploadResult(id, "${server}/files/$id", digest))
return Pair(key, UploadResult(id, "${server}/files/$id", digest))
}
private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: DotNetAPI.UploadResult) {
private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) {
Log.d(TAG, "Attachment uploaded successfully.")
delegate?.handleJobSucceeded(this)
MessagingModuleConfiguration.shared.messageDataProvider.handleSuccessfulAttachmentUpload(attachmentID, attachment, attachmentKey, uploadResult)
@@ -158,7 +148,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
.putString(THREAD_ID_KEY, threadID)
.putByteArray(MESSAGE_KEY, serializedMessage)
.putString(MESSAGE_SEND_JOB_ID_KEY, messageSendJobID)
.build();
.build()
}
override fun getFactoryKey(): String {
@@ -172,7 +162,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess
val kryo = Kryo()
kryo.isRegistrationRequired = false
val input = Input(serializedMessage)
val message: Message = kryo.readObject(input, Message::class.java)
val message = kryo.readObject(input, Message::class.java)
input.close()
return AttachmentUploadJob(
data.getLong(ATTACHMENT_ID_KEY),

View File

@@ -88,17 +88,16 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
override fun serialize(): Data {
val kryo = Kryo()
kryo.isRegistrationRequired = false
val output = Output(ByteArray(4096), MAX_BUFFER_SIZE)
// Message
kryo.writeClassAndObject(output, message)
output.close()
val serializedMessage = output.toBytes()
output.clear()
val messageOutput = Output(ByteArray(4096), MAX_BUFFER_SIZE)
kryo.writeClassAndObject(messageOutput, message)
messageOutput.close()
val serializedMessage = messageOutput.toBytes()
// Destination
kryo.writeClassAndObject(output, destination)
output.close()
val serializedDestination = output.toBytes()
output.clear()
val destinationOutput = Output(ByteArray(4096), MAX_BUFFER_SIZE)
kryo.writeClassAndObject(destinationOutput, destination)
destinationOutput.close()
val serializedDestination = destinationOutput.toBytes()
// Serialize
return Data.Builder()
.putByteArray(MESSAGE_KEY, serializedMessage)
@@ -116,6 +115,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job {
val serializedMessage = data.getByteArray(MESSAGE_KEY)
val serializedDestination = data.getByteArray(DESTINATION_KEY)
val kryo = Kryo()
kryo.isRegistrationRequired = false
// Message
val messageInput = Input(serializedMessage)
val message: Message

View File

@@ -67,7 +67,9 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job {
val output = Output(serializedMessage)
kryo.writeObject(output, message)
output.close()
return Data.Builder().putByteArray(MESSAGE_KEY, serializedMessage).build();
return Data.Builder()
.putByteArray(MESSAGE_KEY, serializedMessage)
.build();
}
override fun getFactoryKey(): String {
@@ -81,7 +83,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job {
val kryo = Kryo()
kryo.isRegistrationRequired = false
val input = Input(serializedMessage)
val message: SnodeMessage = kryo.readObject(input, SnodeMessage::class.java)
val message = kryo.readObject(input, SnodeMessage::class.java)
input.close()
return NotifyPNServerJob(message)
}

View File

@@ -1,22 +1,11 @@
package org.session.libsession.messaging.mentions
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsignal.database.LokiUserDatabaseProtocol
class MentionsManager(private val userPublicKey: String, private val userDatabase: LokiUserDatabaseProtocol) {
object MentionsManager {
var userPublicKeyCache = mutableMapOf<Long, Set<String>>() // Thread ID to set of user hex encoded public keys
companion object {
public lateinit var shared: MentionsManager
public fun configureIfNeeded(userPublicKey: String, userDatabase: LokiUserDatabaseProtocol) {
if (::shared.isInitialized) { return; }
shared = MentionsManager(userPublicKey, userDatabase)
}
}
fun cache(publicKey: String, threadID: Long) {
val cache = userPublicKeyCache[threadID]
if (cache != null) {
@@ -26,20 +15,16 @@ class MentionsManager(private val userPublicKey: String, private val userDatabas
}
}
fun getMentionCandidates(query: String, threadID: Long): List<Mention> {
// Prepare
fun getMentionCandidates(query: String, threadID: Long, isOpenGroup: Boolean): List<Mention> {
val cache = userPublicKeyCache[threadID] ?: return listOf()
// Prepare
val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey()
// Gather candidates
val publicChat = MessagingModuleConfiguration.shared.messageDataProvider.getOpenGroup(threadID)
var candidates: List<Mention> = cache.mapNotNull { publicKey ->
val displayName: String?
if (publicChat != null) {
displayName = userDatabase.getServerDisplayName(publicChat.id, publicKey)
} else {
displayName = userDatabase.getDisplayName(publicKey)
}
if (displayName == null) { return@mapNotNull null }
if (displayName.startsWith("Anonymous")) { return@mapNotNull null }
val contact = storage.getContactWithSessionID(publicKey)
val displayName = contact?.displayName(context) ?: return@mapNotNull null
Mention(publicKey, displayName)
}
candidates = candidates.filter { it.publicKey != userPublicKey }

View File

@@ -2,7 +2,6 @@ package org.session.libsession.messaging.messages
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupV2
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.utilities.toHexString
@@ -15,9 +14,6 @@ sealed class Destination {
class ClosedGroup(var groupPublicKey: String) : Destination() {
internal constructor(): this("")
}
class OpenGroup(var channel: Long, var server: String) : Destination() {
internal constructor(): this(0, "")
}
class OpenGroupV2(var room: String, var server: String) : Destination() {
internal constructor(): this("", "")
}
@@ -36,10 +32,8 @@ sealed class Destination {
}
address.isOpenGroup -> {
val storage = MessagingModuleConfiguration.shared.storage
val threadID = storage.getThreadID(address.contactIdentifier())!!
when (val openGroup = storage.getV2OpenGroup(threadID) ?: storage.getOpenGroup(threadID)) {
is org.session.libsession.messaging.open_groups.OpenGroup
-> Destination.OpenGroup(openGroup.channel, openGroup.server)
val threadID = storage.getThreadId(address)!!
when (val openGroup = storage.getV2OpenGroup(threadID)) {
is org.session.libsession.messaging.open_groups.OpenGroupV2
-> Destination.OpenGroupV2(openGroup.room, openGroup.server)
else -> throw Exception("Missing open group for thread with ID: $threadID.")

View File

@@ -114,10 +114,9 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
closedGroups.add(closedGroup)
}
if (group.isOpenGroup) {
val threadID = storage.getThreadID(group.encodedId) ?: continue
val openGroup = storage.getOpenGroup(threadID)
val threadID = storage.getThreadId(group.encodedId) ?: continue
val openGroupV2 = storage.getV2OpenGroup(threadID)
val shareUrl = openGroup?.server ?: openGroupV2?.joinURL ?: continue
val shareUrl = openGroupV2?.joinURL ?: continue
openGroups.add(shareUrl)
}
}

View File

@@ -1,37 +0,0 @@
package org.session.libsession.messaging.open_groups
import org.session.libsignal.utilities.JsonUtil
data class OpenGroup(
val channel: Long,
private val serverURL: String,
val displayName: String,
val isDeletable: Boolean
) {
val server get() = serverURL.toLowerCase()
val id get() = getId(channel, server)
companion object {
@JvmStatic fun getId(channel: Long, server: String): String {
return "$server.$channel"
}
@JvmStatic fun fromJSON(jsonAsString: String): OpenGroup? {
try {
val json = JsonUtil.fromJson(jsonAsString)
val channel = json.get("channel").asLong()
val server = json.get("server").asText().toLowerCase()
val displayName = json.get("displayName").asText()
val isDeletable = json.get("isDeletable").asBoolean()
return OpenGroup(channel, server, displayName, isDeletable)
} catch (e: Exception) {
return null
}
}
}
fun toJSON(): Map<String, Any> {
return mapOf( "channel" to channel, "server" to server, "displayName" to displayName, "isDeletable" to isDeletable )
}
}

View File

@@ -1,394 +0,0 @@
package org.session.libsession.messaging.open_groups
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.then
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsession.messaging.utilities.DotNetAPI
import org.session.libsession.utilities.DownloadUtilities
import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.*
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.*
object OpenGroupAPI: DotNetAPI() {
private val moderators: HashMap<String, HashMap<Long, Set<String>>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs)
// region Settings
private val fallbackBatchCount = 64
private val maxRetryCount = 8
// endregion
// region Convenience
private val channelInfoType = "net.patter-app.settings"
private val attachmentType = "net.app.core.oembed"
@JvmStatic
val openGroupMessageType = "network.loki.messenger.publicChat"
@JvmStatic
val profilePictureType = "network.loki.messenger.avatar"
fun getDefaultChats(): List<OpenGroup> {
return listOf() // Don't auto-join any open groups right now
}
@JvmStatic
fun isUserModerator(hexEncodedPublicKey: String, channel: Long, server: String): Boolean {
if (moderators[server] != null && moderators[server]!![channel] != null) {
return moderators[server]!![channel]!!.contains(hexEncodedPublicKey)
}
return false
}
// endregion
// region Public API
fun getMessages(channel: Long, server: String): Promise<List<OpenGroupMessage>, Exception> {
Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.")
val storage = MessagingModuleConfiguration.shared.storage
val parameters = mutableMapOf<String, Any>( "include_annotations" to 1 )
val lastMessageServerID = storage.getLastMessageServerID(channel, server)
if (lastMessageServerID != null) {
parameters["since_id"] = lastMessageServerID
} else {
parameters["count"] = fallbackBatchCount
parameters["include_deleted"] = 0
}
return execute(HTTPVerb.GET, server, "channels/$channel/messages", parameters = parameters).then { json ->
try {
val data = json["data"] as List<Map<*, *>>
val messages = data.mapNotNull { message ->
try {
val isDeleted = message["is_deleted"] as? Boolean ?: false
if (isDeleted) { return@mapNotNull null }
// Ignore messages without annotations
if (message["annotations"] == null) { return@mapNotNull null }
val annotation = (message["annotations"] as List<Map<*, *>>).find {
((it["type"] as? String ?: "") == openGroupMessageType) && it["value"] != null
} ?: return@mapNotNull null
val value = annotation["value"] as Map<*, *>
val serverID = message["id"] as? Long ?: (message["id"] as? Int)?.toLong() ?: (message["id"] as String).toLong()
val user = message["user"] as Map<*, *>
val publicKey = user["username"] as String
val displayName = user["name"] as? String ?: "Anonymous"
var profilePicture: OpenGroupMessage.ProfilePicture? = null
if (user["annotations"] != null) {
val profilePictureAnnotation = (user["annotations"] as List<Map< *, *>>).find {
((it["type"] as? String ?: "") == profilePictureType) && it["value"] != null
}
val profilePictureAnnotationValue = profilePictureAnnotation?.get("value") as? Map<*, *>
if (profilePictureAnnotationValue != null && profilePictureAnnotationValue["profileKey"] != null && profilePictureAnnotationValue["url"] != null) {
try {
val profileKey = Base64.decode(profilePictureAnnotationValue["profileKey"] as String)
val url = profilePictureAnnotationValue["url"] as String
profilePicture = OpenGroupMessage.ProfilePicture(profileKey, url)
} catch (e: Exception) {}
}
}
@Suppress("NAME_SHADOWING") val body = message["text"] as String
val timestamp = value["timestamp"] as? Long ?: (value["timestamp"] as? Int)?.toLong() ?: (value["timestamp"] as String).toLong()
var quote: OpenGroupMessage.Quote? = null
if (value["quote"] != null) {
val replyTo = message["reply_to"] as? Long ?: (message["reply_to"] as? Int)?.toLong() ?: (message["reply_to"] as String).toLong()
val quoteAnnotation = value["quote"] as? Map<*, *>
val quoteTimestamp = quoteAnnotation?.get("id") as? Long ?: (quoteAnnotation?.get("id") as? Int)?.toLong() ?: (quoteAnnotation?.get("id") as? String)?.toLong() ?: 0L
val author = quoteAnnotation?.get("author") as? String
val text = quoteAnnotation?.get("text") as? String
quote = if (quoteTimestamp > 0L && author != null && text != null) OpenGroupMessage.Quote(quoteTimestamp, author, text, replyTo) else null
}
val attachmentsAsJSON = (message["annotations"] as List<Map<*, *>>).filter {
((it["type"] as? String ?: "") == attachmentType) && it["value"] != null
}
val attachments = attachmentsAsJSON.mapNotNull { it["value"] as? Map<*, *> }.mapNotNull { attachmentAsJSON ->
try {
val kindAsString = attachmentAsJSON["lokiType"] as String
val kind = OpenGroupMessage.Attachment.Kind.values().first { it.rawValue == kindAsString }
val id = attachmentAsJSON["id"] as? Long ?: (attachmentAsJSON["id"] as? Int)?.toLong() ?: (attachmentAsJSON["id"] as String).toLong()
val contentType = attachmentAsJSON["contentType"] as String
val size = attachmentAsJSON["size"] as? Int ?: (attachmentAsJSON["size"] as? Long)?.toInt() ?: (attachmentAsJSON["size"] as String).toInt()
val fileName = attachmentAsJSON["fileName"] as? String
val flags = 0
val url = attachmentAsJSON["url"] as String
val caption = attachmentAsJSON["caption"] as? String
val linkPreviewURL = attachmentAsJSON["linkPreviewUrl"] as? String
val linkPreviewTitle = attachmentAsJSON["linkPreviewTitle"] as? String
if (kind == OpenGroupMessage.Attachment.Kind.LinkPreview && (linkPreviewURL == null || linkPreviewTitle == null)) {
null
} else {
OpenGroupMessage.Attachment(kind, server, id, contentType, size, fileName, flags, 0, 0, caption, url, linkPreviewURL, linkPreviewTitle)
}
} catch (e: Exception) {
Log.d("Loki","Couldn't parse attachment due to error: $e.")
null
}
}
// Set the last message server ID here to avoid the situation where a message doesn't have a valid signature and this function is called over and over
@Suppress("NAME_SHADOWING") val lastMessageServerID = storage.getLastMessageServerID(channel, server)
if (serverID > lastMessageServerID ?: 0) { storage.setLastMessageServerID(channel, server, serverID) }
val hexEncodedSignature = value["sig"] as String
val signatureVersion = value["sigver"] as? Long ?: (value["sigver"] as? Int)?.toLong() ?: (value["sigver"] as String).toLong()
val signature = OpenGroupMessage.Signature(Hex.fromStringCondensed(hexEncodedSignature), signatureVersion)
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
format.timeZone = TimeZone.getTimeZone("GMT")
val dateAsString = message["created_at"] as String
val serverTimestamp = format.parse(dateAsString).time
// Verify the message
val groupMessage = OpenGroupMessage(serverID, publicKey, displayName, body, timestamp, openGroupMessageType, quote, attachments.toMutableList(), profilePicture, signature, serverTimestamp)
if (groupMessage.hasValidSignature()) groupMessage else null
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server from: ${JsonUtil.toJson(message)}. Exception: ${exception.message}")
return@mapNotNull null
}
}.sortedBy { it.serverTimestamp }
messages
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse messages for open group with ID: $channel on server: $server.")
throw exception
}
}
}
@JvmStatic
fun getDeletedMessageServerIDs(channel: Long, server: String): Promise<List<Long>, Exception> {
Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.")
val storage = MessagingModuleConfiguration.shared.storage
val parameters = mutableMapOf<String, Any>()
val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
if (lastDeletionServerID != null) {
parameters["since_id"] = lastDeletionServerID
} else {
parameters["count"] = fallbackBatchCount
}
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/deletes", parameters = parameters).then { json ->
try {
val deletedMessageServerIDs = (json["data"] as List<Map<*, *>>).mapNotNull { deletion ->
try {
val serverID = deletion["id"] as? Long ?: (deletion["id"] as? Int)?.toLong() ?: (deletion["id"] as String).toLong()
val messageServerID = deletion["message_id"] as? Long ?: (deletion["message_id"] as? Int)?.toLong() ?: (deletion["message_id"] as String).toLong()
@Suppress("NAME_SHADOWING") val lastDeletionServerID = storage.getLastDeletionServerID(channel, server)
if (serverID > (lastDeletionServerID ?: 0)) { storage.setLastDeletionServerID(channel, server, serverID) }
messageServerID
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse deleted message for open group with ID: $channel on server: $server. Exception: ${exception.message}")
return@mapNotNull null
}
}
deletedMessageServerIDs
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse deleted messages for open group with ID: $channel on server: $server.")
throw exception
}
}
}
@JvmStatic
fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise<OpenGroupMessage, Exception> {
val deferred = deferred<OpenGroupMessage, Exception>()
val storage = MessagingModuleConfiguration.shared.storage
val userKeyPair = storage.getUserKeyPair() ?: throw Error.Generic
val userDisplayName = storage.getUserDisplayName() ?: throw Error.Generic
ThreadUtils.queue {
val signedMessage = message.sign(userKeyPair.second)
if (signedMessage == null) {
deferred.reject(Error.SigningFailed)
} else {
retryIfNeeded(maxRetryCount) {
Log.d("Loki", "Sending message to open group with ID: $channel on server: $server.")
val parameters = signedMessage.toJSON()
execute(HTTPVerb.POST, server, "channels/$channel/messages", parameters = parameters).then { json ->
try {
val data = json["data"] as Map<*, *>
val serverID = (data["id"] as? Long) ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as String).toLong()
val text = data["text"] as String
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
format.timeZone = TimeZone.getTimeZone("GMT")
val dateAsString = data["created_at"] as String
val timestamp = format.parse(dateAsString).time
OpenGroupMessage(serverID, userKeyPair.first, userDisplayName, text, timestamp, openGroupMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp)
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server.")
throw exception
}
}
}.success {
deferred.resolve(it)
}.fail {
deferred.reject(it)
}
}
}
return deferred.promise
}
fun deleteMessage(messageServerID: Long, channel: Long, server: String, isSentByUser: Boolean): Promise<Long, Exception> {
return retryIfNeeded(maxRetryCount) {
val isModerationRequest = !isSentByUser
Log.d("Loki", "Deleting message with ID: $messageServerID from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
val endpoint = if (isSentByUser) "channels/$channel/messages/$messageServerID" else "loki/v1/moderation/message/$messageServerID"
execute(HTTPVerb.DELETE, server, endpoint, isJSONRequired = false).then {
Log.d("Loki", "Deleted message with ID: $messageServerID from open group with ID: $channel on server: $server.")
messageServerID
}
}
}
@JvmStatic
fun deleteMessages(messageServerIDs: List<Long>, channel: Long, server: String, isSentByUser: Boolean): Promise<List<Long>, Exception> {
return retryIfNeeded(maxRetryCount) {
val isModerationRequest = !isSentByUser
val parameters = mapOf( "ids" to messageServerIDs.joinToString(",") )
Log.d("Loki", "Deleting messages with IDs: ${messageServerIDs.joinToString()} from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).")
val endpoint = if (isSentByUser) "loki/v1/messages" else "loki/v1/moderation/messages"
execute(HTTPVerb.DELETE, server, endpoint, parameters = parameters, isJSONRequired = false).then { json ->
Log.d("Loki", "Deleted messages with IDs: $messageServerIDs from open group with ID: $channel on server: $server.")
messageServerIDs
}
}
}
@JvmStatic
fun getModerators(channel: Long, server: String): Promise<Set<String>, Exception> {
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then { json ->
try {
@Suppress("UNCHECKED_CAST") val moderators = json["moderators"] as? List<String>
val moderatorsAsSet = moderators.orEmpty().toSet()
if (this.moderators[server] != null) {
this.moderators[server]!![channel] = moderatorsAsSet
} else {
this.moderators[server] = hashMapOf( channel to moderatorsAsSet )
}
moderatorsAsSet
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse moderators for open group with ID: $channel on server: $server.")
throw exception
}
}
}
@JvmStatic
fun getChannelInfo(channel: Long, server: String): Promise<OpenGroupInfo, Exception> {
return retryIfNeeded(maxRetryCount) {
val parameters = mapOf( "include_annotations" to 1 )
execute(HTTPVerb.GET, server, "/channels/$channel", parameters = parameters).then { json ->
try {
val data = json["data"] as Map<*, *>
val annotations = data["annotations"] as List<Map<*, *>>
val annotation = annotations.find { (it["type"] as? String ?: "") == channelInfoType } ?: throw Error.ParsingFailed
val info = annotation["value"] as Map<*, *>
val displayName = info["name"] as String
val countInfo = data["counts"] as Map<*, *>
val memberCount = countInfo["subscribers"] as? Int ?: (countInfo["subscribers"] as? Long)?.toInt() ?: (countInfo["subscribers"] as String).toInt()
val profilePictureURL = info["avatar"] as String
val publicChatInfo = OpenGroupInfo(displayName, profilePictureURL, memberCount)
MessagingModuleConfiguration.shared.storage.setUserCount(channel, server, memberCount)
publicChatInfo
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse info for open group with ID: $channel on server: $server.")
throw exception
}
}
}
}
@JvmStatic
fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) {
val storage = MessagingModuleConfiguration.shared.storage
storage.setUserCount(channel, server, info.memberCount)
storage.updateTitle(groupID, info.displayName)
// Download and update profile picture if needed
val oldProfilePictureURL = storage.getOpenGroupProfilePictureURL(channel, server)
if (isForcedUpdate || oldProfilePictureURL != info.profilePictureURL) {
val profilePictureAsByteArray = downloadOpenGroupProfilePicture(server, info.profilePictureURL) ?: return
storage.updateProfilePicture(groupID, profilePictureAsByteArray)
storage.setOpenGroupProfilePictureURL(channel, server, info.profilePictureURL)
}
}
@JvmStatic
fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? {
val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}"
Log.d("Loki", "Downloading open group profile picture from \"$url\".")
val outputStream = ByteArrayOutputStream()
try {
DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null)
Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"")
return outputStream.toByteArray()
} catch (e: Exception) {
Log.d("Loki", "Failed to download open group profile picture from \"$url\" due to error: $e.")
return null
} finally {
outputStream.close()
}
}
@JvmStatic
fun join(channel: Long, server: String): Promise<Unit, Exception> {
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then {
Log.d("Loki", "Joined channel with ID: $channel on server: $server.")
}
}
}
@JvmStatic
fun leave(channel: Long, server: String): Promise<Unit, Exception> {
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then {
Log.d("Loki", "Left channel with ID: $channel on server: $server.")
}
}
}
@JvmStatic
fun ban(publicKey: String, server: String): Promise<Unit,Exception> {
return retryIfNeeded(maxRetryCount) {
execute(HTTPVerb.POST, server, "/loki/v1/moderation/blacklist/@$publicKey").then {
Log.d("Loki", "Banned user with ID: $publicKey from $server")
}
}
}
@JvmStatic
fun getDisplayNames(publicKeys: Set<String>, server: String): Promise<Map<String, String>, Exception> {
return getUserProfiles(publicKeys, server, false).map { json ->
val mapping = mutableMapOf<String, String>()
for (user in json) {
if (user["username"] != null) {
val publicKey = user["username"] as String
val displayName = user["name"] as? String ?: "Anonymous"
mapping[publicKey] = displayName
}
}
mapping
}
}
@JvmStatic
fun setDisplayName(newDisplayName: String?, server: String): Promise<Unit, Exception> {
Log.d("Loki", "Updating display name on server: $server.")
val parameters = mapOf( "name" to (newDisplayName ?: "") )
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit }
}
@JvmStatic
fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise<Unit, Exception> {
return setProfilePicture(server, Base64.encodeBytes(profileKey), url)
}
fun setProfilePicture(server: String, profileKey: String, url: String?): Promise<Unit, Exception> {
Log.d("Loki", "Updating profile picture on server: $server.")
val value = when (url) {
null -> null
else -> mapOf( "profileKey" to profileKey, "url" to url )
}
// TODO: This may actually completely replace the annotations, have to double check it
return setSelfAnnotation(server, profilePictureType, value).map { Unit }.fail {
Log.d("Loki", "Failed to update profile picture due to error: $it.")
}
}
// endregion
}

View File

@@ -13,8 +13,10 @@ import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerV2
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.AESGCM
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.HTTP.Verb.*
import org.session.libsignal.utilities.removing05PrefixIfNeeded
@@ -30,9 +32,18 @@ object OpenGroupAPIV2 {
private val moderators: HashMap<String, Set<String>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs)
private val curve = Curve25519.getInstance(Curve25519.BEST)
val defaultRooms = MutableSharedFlow<List<DefaultGroup>>(replay = 1)
private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>()
private var hasUpdatedLastOpenDate = false
private const val DEFAULT_SERVER_PUBLIC_KEY = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val DEFAULT_SERVER = "http://116.203.70.33"
private val timeSinceLastOpen by lazy {
val context = MessagingModuleConfiguration.shared.context
val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context)
val now = System.currentTimeMillis()
now - lastOpenDate
}
private const val defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238"
const val defaultServer = "http://116.203.70.33"
sealed class Error(message: String) : Exception(message) {
object Generic : Error("An error occurred.")
@@ -45,7 +56,7 @@ object OpenGroupAPIV2 {
data class DefaultGroup(val id: String, val name: String, val image: ByteArray?) {
val joinURL: String get() = "$DEFAULT_SERVER/$id?public_key=$DEFAULT_SERVER_PUBLIC_KEY"
val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey"
}
data class Info(val id: String, val name: String, val imageID: String?)
@@ -60,19 +71,19 @@ object OpenGroupAPIV2 {
) {
companion object {
val EMPTY = MessageDeletion()
val empty = MessageDeletion()
}
}
data class Request(
val verb: HTTP.Verb,
val room: String?,
val server: String,
val endpoint: String,
val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
val verb: HTTP.Verb,
val room: String?,
val server: String,
val endpoint: String,
val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null,
val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
/**
* Always `true` under normal circumstances. You might want to disable
* this when running over Lokinet.
@@ -125,9 +136,7 @@ object OpenGroupAPIV2 {
if (e is OnionRequestAPI.HTTPRequestFailedAtDestinationException && e.statusCode == 401) {
val storage = MessagingModuleConfiguration.shared.storage
if (request.room != null) {
storage.removeAuthToken("${request.server}.${request.room}")
} else {
storage.removeAuthToken(request.server)
storage.removeAuthToken(request.room, request.server)
}
}
}
@@ -237,7 +246,7 @@ object OpenGroupAPIV2 {
fun getMessages(room: String, server: String): Promise<List<OpenGroupMessageV2>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastMessageServerId(room, server)?.let { lastId ->
storage.getLastMessageServerID(room, server)?.let { lastId ->
queryParameters += "from_server_id" to lastId.toString()
}
val request = Request(verb = GET, room = room, server = server, endpoint = "messages", queryParameters = queryParameters)
@@ -250,7 +259,7 @@ object OpenGroupAPIV2 {
private fun parseMessages(room: String, server: String, rawMessages: List<Map<*, *>>): List<OpenGroupMessageV2> {
val storage = MessagingModuleConfiguration.shared.storage
val lastMessageServerID = storage.getLastMessageServerId(room, server) ?: 0
val lastMessageServerID = storage.getLastMessageServerID(room, server) ?: 0
var currentLastMessageServerID = lastMessageServerID
val messages = rawMessages.mapNotNull { json ->
json as Map<String, Any>
@@ -274,7 +283,7 @@ object OpenGroupAPIV2 {
null
}
}
storage.setLastMessageServerId(room, server, currentLastMessageServerID)
storage.setLastMessageServerID(room, server, currentLastMessageServerID)
return messages
}
// endregion
@@ -291,7 +300,7 @@ object OpenGroupAPIV2 {
fun getDeletedMessages(room: String, server: String): Promise<List<MessageDeletion>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val queryParameters = mutableMapOf<String, String>()
storage.getLastDeletionServerId(room, server)?.let { last ->
storage.getLastDeletionServerID(room, server)?.let { last ->
queryParameters["from_server_id"] = last.toString()
}
val request = Request(verb = GET, room = room, server = server, endpoint = "deleted_messages", queryParameters = queryParameters)
@@ -299,10 +308,10 @@ object OpenGroupAPIV2 {
val type = TypeFactory.defaultInstance().constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["ids"])
val serverIDs = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type) ?: throw Error.ParsingFailed
val lastMessageServerId = storage.getLastDeletionServerId(room, server) ?: 0
val serverID = serverIDs.maxByOrNull {it.id } ?: MessageDeletion.EMPTY
val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0
val serverID = serverIDs.maxByOrNull {it.id } ?: MessageDeletion.empty
if (serverID.id > lastMessageServerId) {
storage.setLastDeletionServerId(room, server, serverID.id)
storage.setLastDeletionServerID(room, server, serverID.id)
}
serverIDs
}
@@ -351,6 +360,15 @@ object OpenGroupAPIV2 {
fun compactPoll(rooms: List<String>, server: String): Promise<Map<String, CompactPollResult>, Exception> {
val authTokenRequests = rooms.associateWith { room -> getAuthToken(room, server) }
val storage = MessagingModuleConfiguration.shared.storage
val context = MessagingModuleConfiguration.shared.context
val timeSinceLastOpen = this.timeSinceLastOpen
val useMessageLimit = (hasPerformedInitialPoll[server] != true
&& timeSinceLastOpen > OpenGroupPollerV2.maxInactivityPeriod)
hasPerformedInitialPoll[server] = true
if (!hasUpdatedLastOpenDate) {
hasUpdatedLastOpenDate = true
TextSecurePreferences.setLastOpenDate(context)
}
val requests = rooms.mapNotNull { room ->
val authToken = try {
authTokenRequests[room]?.get()
@@ -361,8 +379,8 @@ object OpenGroupAPIV2 {
CompactPollRequest(
roomID = room,
authToken = authToken,
fromDeletionServerID = storage.getLastDeletionServerId(room, server),
fromMessageServerID = storage.getLastMessageServerId(room, server)
fromDeletionServerID = if (useMessageLimit) null else storage.getLastDeletionServerID(room, server),
fromMessageServerID = if (useMessageLimit) null else storage.getLastMessageServerID(room, server)
)
}
val request = Request(verb = POST, room = null, server = server, endpoint = "compact_poll", isAuthRequired = false, parameters = mapOf( "requests" to requests ))
@@ -386,10 +404,10 @@ object OpenGroupAPIV2 {
val type = TypeFactory.defaultInstance().constructCollectionType(List::class.java, MessageDeletion::class.java)
val idsAsString = JsonUtil.toJson(json["deletions"])
val deletedServerIDs = JsonUtil.fromJson<List<MessageDeletion>>(idsAsString, type) ?: throw Error.ParsingFailed
val lastDeletionServerID = storage.getLastDeletionServerId(roomID, server) ?: 0
val serverID = deletedServerIDs.maxByOrNull { it.id } ?: MessageDeletion.EMPTY
val lastDeletionServerID = storage.getLastDeletionServerID(roomID, server) ?: 0
val serverID = deletedServerIDs.maxByOrNull { it.id } ?: MessageDeletion.empty
if (serverID.id > lastDeletionServerID) {
storage.setLastDeletionServerId(roomID, server, serverID.id)
storage.setLastDeletionServerID(roomID, server, serverID.id)
}
// Messages
val rawMessages = json["messages"] as? List<Map<String, Any>> ?: return@mapNotNull null
@@ -405,8 +423,8 @@ object OpenGroupAPIV2 {
fun getDefaultRoomsIfNeeded(): Promise<List<DefaultGroup>, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
storage.setOpenGroupPublicKey(DEFAULT_SERVER, DEFAULT_SERVER_PUBLIC_KEY)
return getAllRooms(DEFAULT_SERVER).map { groups ->
storage.setOpenGroupPublicKey(defaultServer, defaultServerPublicKey)
return getAllRooms(defaultServer).map { groups ->
val earlyGroups = groups.map { group ->
DefaultGroup(group.id, group.name, null)
}
@@ -417,7 +435,7 @@ object OpenGroupAPIV2 {
}
}
val images = groups.map { group ->
group.id to downloadOpenGroupProfilePicture(group.id, DEFAULT_SERVER)
group.id to downloadOpenGroupProfilePicture(group.id, defaultServer)
}.toMap()
groups.map { group ->
val image = try {

View File

@@ -1,247 +0,0 @@
package org.session.libsession.messaging.open_groups
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsignal.utilities.removing05PrefixIfNeeded
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.Log
import org.whispersystems.curve25519.Curve25519
data class OpenGroupMessage(
val serverID: Long?,
val senderPublicKey: String,
val displayName: String,
val body: String,
val timestamp: Long,
val type: String,
val quote: Quote?,
val attachments: MutableList<Attachment>,
val profilePicture: ProfilePicture?,
val signature: Signature?,
val serverTimestamp: Long,
) {
// region Settings
companion object {
fun from(message: VisibleMessage, server: String): OpenGroupMessage? {
val storage = MessagingModuleConfiguration.shared.storage
val userPublicKey = storage.getUserPublicKey() ?: return null
val attachmentIDs = message.attachmentIDs
// Validation
if (!message.isValid()) { return null } // Should be valid at this point
// Quote
val quote: Quote? = {
val quote = message.quote
if (quote != null && quote.isValid()) {
val quotedMessageBody = quote.text ?: quote.timestamp!!.toString()
val serverID = storage.getQuoteServerID(quote.timestamp!!, quote.publicKey!!)
Quote(quote.timestamp!!, quote.publicKey!!, quotedMessageBody, serverID)
} else {
null
}
}()
// Message
val displayname = storage.getUserDisplayName() ?: "Anonymous"
val text = message.text
val body = if (text.isNullOrEmpty()) message.sentTimestamp.toString() else text // The back-end doesn't accept messages without a body so we use this as a workaround
val result = OpenGroupMessage(null, userPublicKey, displayname, body, message.sentTimestamp!!, OpenGroupAPI.openGroupMessageType, quote, mutableListOf(), null, null, 0)
// Link preview
val linkPreview = message.linkPreview
linkPreview?.let {
if (!linkPreview.isValid()) { return@let }
val attachmentID = linkPreview.attachmentID ?: return@let
val attachment = MessagingModuleConfiguration.shared.messageDataProvider.getSignalAttachmentPointer(attachmentID) ?: return@let
val openGroupLinkPreview = Attachment(
Attachment.Kind.LinkPreview,
server,
attachment.id,
attachment.contentType!!,
attachment.size.get(),
attachment.fileName.orNull(),
0,
attachment.width,
attachment.height,
attachment.caption.orNull(),
attachment.url,
linkPreview.url,
linkPreview.title)
result.attachments.add(openGroupLinkPreview)
}
// Attachments
val attachments = message.attachmentIDs.mapNotNull {
val attachment = MessagingModuleConfiguration.shared.messageDataProvider.getSignalAttachmentPointer(it) ?: return@mapNotNull null
return@mapNotNull Attachment(
Attachment.Kind.Attachment,
server,
attachment.id,
attachment.contentType!!,
attachment.size.orNull(),
attachment.fileName.orNull() ?: "",
0,
attachment.width,
attachment.height,
attachment.caption.orNull(),
attachment.url,
null,
null)
}
result.attachments.addAll(attachments)
// Return
return result
}
private val curve = Curve25519.getInstance(Curve25519.BEST)
private val signatureVersion: Long = 1
private val attachmentType = "net.app.core.oembed"
}
// endregion
// region Types
data class ProfilePicture(
val profileKey: ByteArray,
val url: String,
)
data class Quote(
val quotedMessageTimestamp: Long,
val quoteePublicKey: String,
val quotedMessageBody: String,
val quotedMessageServerID: Long? = null,
)
data class Signature(
val data: ByteArray,
val version: Long,
)
data class Attachment(
val kind: Kind,
val server: String,
val serverID: Long,
val contentType: String,
val size: Int,
val fileName: String?,
val flags: Int,
val width: Int,
val height: Int,
val caption: String?,
val url: String,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
val linkPreviewURL: String?,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
val linkPreviewTitle: String?,
) {
val dotNetAPIType = when {
contentType.startsWith("image") -> "photo"
contentType.startsWith("video") -> "video"
contentType.startsWith("audio") -> "audio"
else -> "other"
}
enum class Kind(val rawValue: String) {
Attachment("attachment"), LinkPreview("preview")
}
}
// endregion
// region Initialization
constructor(hexEncodedPublicKey: String, displayName: String, body: String, timestamp: Long, type: String, quote: Quote?, attachments: List<Attachment>)
: this(null, hexEncodedPublicKey, displayName, body, timestamp, type, quote, attachments as MutableList<Attachment>, null, null, 0)
// endregion
// region Crypto
internal fun sign(privateKey: ByteArray): OpenGroupMessage? {
val data = getValidationData(signatureVersion)
if (data == null) {
Log.d("Loki", "Failed to sign public chat message.")
return null
}
try {
val signatureData = curve.calculateSignature(privateKey, data)
val signature = Signature(signatureData, signatureVersion)
return copy(signature = signature)
} catch (e: Exception) {
Log.d("Loki", "Failed to sign public chat message due to error: ${e.message}.")
return null
}
}
internal fun hasValidSignature(): Boolean {
if (signature == null) { return false }
val data = getValidationData(signature.version) ?: return false
val publicKey = Hex.fromStringCondensed(senderPublicKey.removing05PrefixIfNeeded())
try {
return curve.verifySignature(publicKey, data, signature.data)
} catch (e: Exception) {
Log.d("Loki", "Failed to verify public chat message due to error: ${e.message}.")
return false
}
}
// endregion
// region Parsing
internal fun toJSON(): Map<String, Any> {
val value = mutableMapOf<String, Any>("timestamp" to timestamp)
if (quote != null) {
value["quote"] = mapOf("id" to quote.quotedMessageTimestamp, "author" to quote.quoteePublicKey, "text" to quote.quotedMessageBody)
}
if (signature != null) {
value["sig"] = Hex.toStringCondensed(signature.data)
value["sigver"] = signature.version
}
val annotation = mapOf("type" to type, "value" to value)
val annotations = mutableListOf(annotation)
attachments.forEach { attachment ->
val attachmentValue = mutableMapOf(
// Fields required by the .NET API
"version" to 1,
"type" to attachment.dotNetAPIType,
// Custom fields
"lokiType" to attachment.kind.rawValue,
"server" to attachment.server,
"id" to attachment.serverID,
"contentType" to attachment.contentType,
"size" to attachment.size,
"fileName" to attachment.fileName,
"flags" to attachment.flags,
"width" to attachment.width,
"height" to attachment.height,
"url" to attachment.url
)
if (attachment.caption != null) { attachmentValue["caption"] = attachment.caption }
if (attachment.linkPreviewURL != null) { attachmentValue["linkPreviewUrl"] = attachment.linkPreviewURL }
if (attachment.linkPreviewTitle != null) { attachmentValue["linkPreviewTitle"] = attachment.linkPreviewTitle }
val attachmentAnnotation = mapOf("type" to attachmentType, "value" to attachmentValue)
annotations.add(attachmentAnnotation)
}
val result = mutableMapOf("text" to body, "annotations" to annotations)
if (quote?.quotedMessageServerID != null) {
result["reply_to"] = quote.quotedMessageServerID
}
return result
}
// endregion
// region Convenience
private fun getValidationData(signatureVersion: Long): ByteArray? {
var string = "${body.trim()}$timestamp"
if (quote != null) {
string += "${quote.quotedMessageTimestamp}${quote.quoteePublicKey}${quote.quotedMessageBody.trim()}"
if (quote.quotedMessageServerID != null) {
string += "${quote.quotedMessageServerID}"
}
}
string += attachments.sortedBy { it.serverID }.map { it.serverID }.joinToString("")
string += "$signatureVersion"
try {
return string.toByteArray(Charsets.UTF_8)
} catch (exception: Exception) {
return null
}
}
// endregion
}

View File

@@ -54,10 +54,11 @@ object MessageSender {
// Convenience
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
if (destination is Destination.OpenGroup || destination is Destination.OpenGroupV2) {
if (destination is Destination.OpenGroupV2) {
return sendToOpenGroupDestination(destination, message)
} else {
return sendToSnodeDestination(destination, message)
}
return sendToSnodeDestination(destination, message)
}
// One-on-One Chats & Closed Groups
@@ -84,7 +85,7 @@ object MessageSender {
when (destination) {
is Destination.Contact -> message.recipient = destination.publicKey
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
is Destination.OpenGroup, is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be an open group.")
is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be an open group.")
}
// Validate the message
if (!message.isValid()) { throw Error.InvalidMessage }
@@ -122,7 +123,7 @@ object MessageSender {
val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!!
ciphertext = MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey)
}
is Destination.OpenGroup, is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.")
is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.")
}
// Wrap the result
val kind: SignalServiceProtos.Envelope.Type
@@ -136,7 +137,7 @@ object MessageSender {
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE
senderPublicKey = destination.groupPublicKey
}
is Destination.OpenGroup, is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.")
is Destination.OpenGroupV2 -> throw IllegalStateException("Destination should not be open group.")
}
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
// Send the result
@@ -162,9 +163,11 @@ object MessageSender {
}
handleSuccessfulMessageSend(message, destination, isSyncMessage)
var shouldNotify = (message is VisibleMessage && !isSyncMessage)
/*
if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) {
shouldNotify = true
}
*/
if (shouldNotify) {
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
JobQueue.shared.add(notifyPNServerJob)
@@ -203,27 +206,6 @@ object MessageSender {
try {
when (destination) {
is Destination.Contact, is Destination.ClosedGroup -> throw IllegalStateException("Invalid destination.")
is Destination.OpenGroup -> {
message.recipient = "${destination.server}.${destination.channel}"
val server = destination.server
val channel = destination.channel
// Validate the message
if (message !is VisibleMessage || !message.isValid()) {
throw Error.InvalidMessage
}
// Convert the message to an open group message
val openGroupMessage = OpenGroupMessage.from(message, server) ?: run {
throw Error.InvalidMessage
}
// Send the result
OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success {
message.openGroupServerMessageID = it.serverID
handleSuccessfulMessageSend(message, destination)
deferred.resolve(Unit)
}.fail {
handleFailure(it)
}
}
is Destination.OpenGroupV2 -> {
message.recipient = "${destination.server}.${destination.room}"
val server = destination.server
@@ -275,7 +257,7 @@ object MessageSender {
// Track the open group server message ID
if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) {
val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray())
val threadID = storage.getThreadIdFor(Address.fromSerialized(encoded))
val threadID = storage.getThreadId(Address.fromSerialized(encoded))
if (threadID != null && threadID >= 0) {
storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage())
}

View File

@@ -9,6 +9,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences
@@ -71,6 +72,8 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str
storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTime)
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey)
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
// Fulfill the promise
deferred.resolve(groupID)
}
@@ -189,8 +192,8 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St
// Save the new group members
storage.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
// Update the zombie list
val oldZombies = storage.getZombieMember(groupID)
storage.updateZombieMembers(groupID, oldZombies.minus(membersToRemove).map { Address.fromSerialized(it) })
val oldZombies = storage.getZombieMembers(groupID)
storage.setZombieMembers(groupID, oldZombies.minus(membersToRemove).map { Address.fromSerialized(it) })
val removeMembersAsData = membersToRemove.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }
val name = group.title
// Send the update to the group

View File

@@ -12,6 +12,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.PointerAtt
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
@@ -68,21 +69,21 @@ private fun MessageReceiver.handleTypingIndicator(message: TypingIndicator) {
fun MessageReceiver.showTypingIndicatorIfNeeded(senderPublicKey: String) {
val context = MessagingModuleConfiguration.shared.context
val address = Address.fromSerialized(senderPublicKey)
val threadID = MessagingModuleConfiguration.shared.storage.getThreadIdFor(address) ?: return
val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) ?: return
SSKEnvironment.shared.typingIndicators.didReceiveTypingStartedMessage(context, threadID, address, 1)
}
fun MessageReceiver.hideTypingIndicatorIfNeeded(senderPublicKey: String) {
val context = MessagingModuleConfiguration.shared.context
val address = Address.fromSerialized(senderPublicKey)
val threadID = MessagingModuleConfiguration.shared.storage.getThreadIdFor(address) ?: return
val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) ?: return
SSKEnvironment.shared.typingIndicators.didReceiveTypingStoppedMessage(context, threadID, address, 1, false)
}
fun MessageReceiver.cancelTypingIndicatorsIfNeeded(senderPublicKey: String) {
val context = MessagingModuleConfiguration.shared.context
val address = Address.fromSerialized(senderPublicKey)
val threadID = MessagingModuleConfiguration.shared.storage.getThreadIdFor(address) ?: return
val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) ?: return
SSKEnvironment.shared.typingIndicators.didReceiveIncomingMessage(context, threadID, address, 1)
}
@@ -122,22 +123,25 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name,
closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!)
}
val allOpenGroups = storage.getAllOpenGroups().map { it.value.server }
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.joinURL }
for (openGroup in message.openGroups) {
if (allOpenGroups.contains(openGroup) || allV2OpenGroups.contains(openGroup)) continue
storage.addOpenGroup(openGroup, 1)
if (allV2OpenGroups.contains(openGroup)) continue
storage.addOpenGroup(openGroup)
}
val profileManager = SSKEnvironment.shared.profileManager
val recipient = Recipient.from(context, Address.fromSerialized(userPublicKey), false)
if (message.displayName.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, message.displayName)
storage.setDisplayName(userPublicKey, message.displayName)
profileManager.setName(context, recipient, message.displayName)
}
if (message.profileKey.isNotEmpty() && !message.profilePicture.isNullOrEmpty()
&& TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) {
val profileKey = Base64.encodeBytes(message.profileKey)
ProfileKeyUtil.setEncodedProfileKey(context, profileKey)
storage.setProfileKeyForRecipient(userPublicKey, message.profileKey)
storage.setUserProfilePictureUrl(message.profilePicture!!)
profileManager.setProfileKey(context, recipient, message.profileKey)
if (!message.profilePicture.isNullOrEmpty() && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) {
storage.setUserProfilePictureURL(message.profilePicture!!)
}
}
storage.addContacts(message.contacts)
}
@@ -157,17 +161,14 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS
// Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread
throw MessageReceiver.Error.NoThread
}
val openGroup = threadID.let {
storage.getOpenGroup(it.toString())
}
// Update profile if needed
val profile = message.profile
if (profile != null && userPublicKey != message.sender && openGroup == null) { // Don't do this in V1 open groups
if (profile != null && userPublicKey != message.sender) {
val profileManager = SSKEnvironment.shared.profileManager
val recipient = Recipient.from(context, Address.fromSerialized(message.sender!!), false)
val displayName = profile.displayName!!
if (displayName.isNotEmpty()) {
profileManager.setDisplayName(context, recipient, displayName)
val name = profile.displayName!!
if (name.isNotEmpty()) {
profileManager.setName(context, recipient, name)
}
if (profile.profileKey?.isNotEmpty() == true && profile.profilePictureURL?.isNotEmpty() == true
&& (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, profile.profileKey))) {
@@ -286,6 +287,8 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli
storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey)
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, storage.getUserPublicKey()!!)
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
}
private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGroupControlMessage) {
@@ -389,9 +392,9 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo
storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
// update zombie members in case the added members are zombies
val zombies = storage.getZombieMember(groupID)
val zombies = storage.getZombieMembers(groupID)
if (zombies.intersect(updateMembers).isNotEmpty())
storage.updateZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) })
storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) })
// Notify the user
if (userPublicKey == senderPublicKey) {
@@ -451,7 +454,7 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
val members = group.members.map { it.serialize() }
val admins = group.admins.map { it.toString() }
val removedMembers = kind.members.map { it.toByteArray().toHexString() }
val zombies = storage.getZombieMember(groupID)
val zombies: Set<String> = storage.getZombieMembers(groupID)
// Check that the admin wasn't removed
if (removedMembers.contains(admins.first())) {
Log.d("Loki", "Ignoring invalid closed group update.")
@@ -478,7 +481,7 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
} else {
storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
// Update zombie members
storage.updateZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) })
storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) })
}
// Notify the user
@@ -532,8 +535,8 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont
} else {
storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) })
// Update zombie members
val zombies = storage.getZombieMember(groupID)
storage.updateZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) })
val zombies = storage.getZombieMembers(groupID)
storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) })
}
// Notify the user
if (userLeft) {
@@ -569,5 +572,7 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
}
// endregion

View File

@@ -1,82 +0,0 @@
package org.session.libsession.messaging.sending_receiving.pollers
import android.os.Handler
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.crypto.getRandomElementOrNull
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.successBackground
class ClosedGroupPoller {
private var isPolling = false
private val handler: Handler by lazy { Handler() }
private val task = object : Runnable {
override fun run() {
poll()
handler.postDelayed(this, pollInterval)
}
}
// region Settings
companion object {
private val pollInterval: Long = 6 * 1000
}
// endregion
// region Error
class InsufficientSnodesException() : Exception("No snodes left to poll.")
class PollingCanceledException() : Exception("Polling canceled.")
// endregion
// region Public API
public fun startIfNeeded() {
if (isPolling) { return }
isPolling = true
task.run()
}
public fun pollOnce(): List<Promise<Unit, Exception>> {
if (isPolling) { return listOf() }
isPolling = true
return poll()
}
public fun stopIfNeeded() {
isPolling = false
handler.removeCallbacks(task)
}
// endregion
// region Private API
private fun poll(): List<Promise<Unit, Exception>> {
if (!isPolling) { return listOf() }
val storage = MessagingModuleConfiguration.shared.storage
val publicKeys = storage.getAllActiveClosedGroupPublicKeys()
return publicKeys.map { publicKey ->
val promise = SnodeAPI.getSwarm(publicKey).bind { swarm ->
val snode = swarm.getRandomElementOrNull() ?: throw InsufficientSnodesException() // Should be cryptographically secure
if (!isPolling) { throw PollingCanceledException() }
SnodeAPI.getRawMessages(snode, publicKey).map {SnodeAPI.parseRawMessagesResponse(it, snode, publicKey) }
}
promise.successBackground { messages ->
if (!storage.isGroupActive(publicKey)) { return@successBackground }
messages.forEach { envelope ->
val job = MessageReceiveJob(envelope.toByteArray())
JobQueue.shared.add(job)
}
}
promise.fail {
Log.d("Loki", "Polling failed for closed group with public key: $publicKey due to error: $it.")
}
promise.map { }
}
}
// endregion
}

View File

@@ -0,0 +1,115 @@
package org.session.libsession.messaging.sending_receiving.pollers
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.crypto.getRandomElementOrNull
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.successBackground
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import kotlin.math.min
class ClosedGroupPollerV2 {
private val executorService = Executors.newScheduledThreadPool(4)
private var isPolling = mutableMapOf<String, Boolean>()
private var futures = mutableMapOf<String, ScheduledFuture<*>>()
private fun isPolling(groupPublicKey: String): Boolean {
return isPolling[groupPublicKey] ?: false
}
companion object {
private val minPollInterval = 4 * 1000
private val maxPollInterval = 4 * 60 * 1000
@JvmStatic
val shared = ClosedGroupPollerV2()
}
class InsufficientSnodesException() : Exception("No snodes left to poll.")
class PollingCanceledException() : Exception("Polling canceled.")
fun start() {
val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.forEach { startPolling(it) }
}
fun startPolling(groupPublicKey: String) {
if (isPolling(groupPublicKey)) { return }
isPolling[groupPublicKey] = true
setUpPolling(groupPublicKey)
}
fun stop() {
val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.forEach { stopPolling(it) }
}
fun stopPolling(groupPublicKey: String) {
futures[groupPublicKey]?.cancel(false)
isPolling[groupPublicKey] = false
}
private fun setUpPolling(groupPublicKey: String) {
poll(groupPublicKey).success {
pollRecursively(groupPublicKey)
}.fail {
// The error is logged in poll(_:)
pollRecursively(groupPublicKey)
}
}
private fun pollRecursively(groupPublicKey: String) {
if (!isPolling(groupPublicKey)) { return }
// Get the received date of the last message in the thread. If we don't have any messages yet, pick some
// reasonable fake time interval to use instead.
val storage = MessagingModuleConfiguration.shared.storage
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val threadID = storage.getThreadId(groupID) ?: return
val lastUpdated = storage.getLastUpdated(threadID)
val timeSinceLastMessage = if (lastUpdated != -1L) Date().time - lastUpdated else 5 * 60 * 1000
val minPollInterval = Companion.minPollInterval
val limit: Long = 12 * 60 * 60 * 1000
val a = (Companion.maxPollInterval - minPollInterval).toDouble() / limit.toDouble()
val nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval
Log.d("Loki", "Next poll interval for closed group with public key: $groupPublicKey is ${nextPollInterval / 1000} s.")
executorService?.schedule({
poll(groupPublicKey).success {
pollRecursively(groupPublicKey)
}.fail {
// The error is logged in poll(_:)
pollRecursively(groupPublicKey)
}
}, nextPollInterval.toLong(), TimeUnit.MILLISECONDS)
}
fun poll(groupPublicKey: String): Promise<Unit, Exception> {
if (!isPolling(groupPublicKey)) { return Promise.of(Unit) }
val promise = SnodeAPI.getSwarm(groupPublicKey).bind { swarm ->
val snode = swarm.getRandomElementOrNull() ?: throw InsufficientSnodesException() // Should be cryptographically secure
if (!isPolling(groupPublicKey)) { throw PollingCanceledException() }
SnodeAPI.getRawMessages(snode, groupPublicKey).map { SnodeAPI.parseRawMessagesResponse(it, snode, groupPublicKey) }
}
promise.success { envelopes ->
if (!isPolling(groupPublicKey)) { return@success }
envelopes.forEach { envelope ->
val job = MessageReceiveJob(envelope.toByteArray())
JobQueue.shared.add(job)
}
}
promise.fail {
Log.d("Loki", "Polling failed for closed group with public key: $groupPublicKey due to error: $it.")
}
return promise.map { }
}
}

View File

@@ -1,232 +0,0 @@
package org.session.libsession.messaging.sending_receiving.pollers
import com.google.protobuf.ByteString
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupAPI
import org.session.libsession.messaging.open_groups.OpenGroupMessage
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.protos.SignalServiceProtos.*
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.successBackground
import java.util.*
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
class OpenGroupPoller(private val openGroup: OpenGroup, private val executorService: ScheduledExecutorService? = null) {
private var hasStarted = false
@Volatile private var isPollOngoing = false
var isCaughtUp = false
private val cancellableFutures = mutableListOf<ScheduledFuture<out Any>>()
// region Convenience
private val userHexEncodedPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: ""
private var displayNameUpdates = setOf<String>()
// endregion
// region Settings
companion object {
private val pollForNewMessagesInterval: Long = 10 * 1000
private val pollForDeletedMessagesInterval: Long = 60 * 1000
private val pollForModeratorsInterval: Long = 10 * 60 * 1000
private val pollForDisplayNamesInterval: Long = 60 * 1000
}
// endregion
// region Lifecycle
fun startIfNeeded() {
if (hasStarted || executorService == null) return
cancellableFutures += listOf(
executorService.scheduleAtFixedRate(::pollForNewMessages,0, pollForNewMessagesInterval, TimeUnit.MILLISECONDS),
executorService.scheduleAtFixedRate(::pollForDeletedMessages,0, pollForDeletedMessagesInterval, TimeUnit.MILLISECONDS),
executorService.scheduleAtFixedRate(::pollForModerators,0, pollForModeratorsInterval, TimeUnit.MILLISECONDS),
executorService.scheduleAtFixedRate(::pollForDisplayNames,0, pollForDisplayNamesInterval, TimeUnit.MILLISECONDS)
)
hasStarted = true
}
fun stop() {
cancellableFutures.forEach { future ->
future.cancel(false)
}
cancellableFutures.clear()
hasStarted = false
}
// endregion
// region Polling
fun pollForNewMessages(): Promise<Unit, Exception> {
return pollForNewMessagesInternal(false)
}
private fun pollForNewMessagesInternal(isBackgroundPoll: Boolean): Promise<Unit, Exception> {
if (isPollOngoing) { return Promise.of(Unit) }
isPollOngoing = true
val deferred = deferred<Unit, Exception>()
// Kovenant propagates a context to chained promises, so OpenGroupAPI.sharedContext should be used for all of the below
OpenGroupAPI.getMessages(openGroup.channel, openGroup.server).successBackground { messages ->
// Process messages in the background
messages.forEach { message ->
try {
val senderPublicKey = message.senderPublicKey
fun generateDisplayName(rawDisplayName: String): String {
return "$rawDisplayName (...${senderPublicKey.takeLast(8)})"
}
val senderDisplayName = MessagingModuleConfiguration.shared.storage.getOpenGroupDisplayName(senderPublicKey, openGroup.channel, openGroup.server) ?: generateDisplayName(message.displayName)
val id = openGroup.id.toByteArray()
// Main message
val dataMessageProto = DataMessage.newBuilder()
val body = if (message.body == message.timestamp.toString()) { "" } else { message.body }
dataMessageProto.setBody(body)
dataMessageProto.setTimestamp(message.timestamp)
// Attachments
val attachmentProtos = message.attachments.mapNotNull { attachment ->
try {
if (attachment.kind != OpenGroupMessage.Attachment.Kind.Attachment) { return@mapNotNull null }
val attachmentProto = AttachmentPointer.newBuilder()
attachmentProto.setId(attachment.serverID)
attachmentProto.setContentType(attachment.contentType)
attachmentProto.setSize(attachment.size)
attachmentProto.setFileName(attachment.fileName)
attachmentProto.setFlags(attachment.flags)
attachmentProto.setWidth(attachment.width)
attachmentProto.setHeight(attachment.height)
attachment.caption?.let { attachmentProto.setCaption(it) }
attachmentProto.setUrl(attachment.url)
attachmentProto.build()
} catch (e: Exception) {
Log.e("Loki","Failed to parse attachment as proto",e)
null
}
}
dataMessageProto.addAllAttachments(attachmentProtos)
// Link preview
val linkPreview = message.attachments.firstOrNull { it.kind == OpenGroupMessage.Attachment.Kind.LinkPreview }
if (linkPreview != null) {
val linkPreviewProto = DataMessage.Preview.newBuilder()
linkPreviewProto.setUrl(linkPreview.linkPreviewURL!!)
linkPreviewProto.setTitle(linkPreview.linkPreviewTitle!!)
val attachmentProto = AttachmentPointer.newBuilder()
attachmentProto.setId(linkPreview.serverID)
attachmentProto.setContentType(linkPreview.contentType)
attachmentProto.setSize(linkPreview.size)
attachmentProto.setFileName(linkPreview.fileName)
attachmentProto.setFlags(linkPreview.flags)
attachmentProto.setWidth(linkPreview.width)
attachmentProto.setHeight(linkPreview.height)
linkPreview.caption?.let { attachmentProto.setCaption(it) }
attachmentProto.setUrl(linkPreview.url)
linkPreviewProto.setImage(attachmentProto.build())
dataMessageProto.addPreview(linkPreviewProto.build())
}
// Quote
val quote = message.quote
if (quote != null) {
val quoteProto = DataMessage.Quote.newBuilder()
quoteProto.setId(quote.quotedMessageTimestamp)
quoteProto.setAuthor(quote.quoteePublicKey)
if (quote.quotedMessageBody != quote.quotedMessageTimestamp.toString()) { quoteProto.setText(quote.quotedMessageBody) }
dataMessageProto.setQuote(quoteProto.build())
}
val messageServerID = message.serverID
// Profile
val profileProto = DataMessage.LokiProfile.newBuilder()
profileProto.setDisplayName(senderDisplayName)
val profilePicture = message.profilePicture
if (profilePicture != null) {
profileProto.setProfilePicture(profilePicture.url)
dataMessageProto.setProfileKey(ByteString.copyFrom(profilePicture.profileKey))
}
dataMessageProto.setProfile(profileProto.build())
/* TODO: the signal service proto needs to be synced with iOS
// Open group info
if (messageServerID != null) {
val openGroupProto = PublicChatInfo.newBuilder()
openGroupProto.setServerID(messageServerID)
dataMessageProto.setPublicChatInfo(openGroupProto.build())
}
*/
// Signal group context
val groupProto = GroupContext.newBuilder()
groupProto.setId(ByteString.copyFrom(id))
groupProto.setType(GroupContext.Type.DELIVER)
groupProto.setName(openGroup.displayName)
dataMessageProto.setGroup(groupProto.build())
// Content
val content = Content.newBuilder()
content.setDataMessage(dataMessageProto.build())
// Envelope
val builder = Envelope.newBuilder()
builder.type = Envelope.Type.SESSION_MESSAGE
builder.source = senderPublicKey
builder.sourceDevice = 1
builder.setContent(content.build().toByteString())
builder.timestamp = message.timestamp
builder.serverTimestamp = message.serverTimestamp
val envelope = builder.build()
val job = MessageReceiveJob(envelope.toByteArray(), messageServerID, openGroup.id)
Log.d("Loki", "Scheduling Job $job")
if (isBackgroundPoll) {
job.executeAsync().always { deferred.resolve(Unit) }
// The promise is just used to keep track of when we're done
} else {
JobQueue.shared.add(job)
}
} catch (e: Exception) {
Log.e("Loki", "Exception parsing message", e)
}
}
displayNameUpdates = displayNameUpdates + messages.map { it.senderPublicKey }.toSet() - userHexEncodedPublicKey
executorService?.schedule(::pollForDisplayNames, 0, TimeUnit.MILLISECONDS)
isCaughtUp = true
isPollOngoing = false
deferred.resolve(Unit)
}.fail {
Log.d("Loki", "Failed to get messages for group chat with ID: ${openGroup.channel} on server: ${openGroup.server}.")
isPollOngoing = false
}
return deferred.promise
}
private fun pollForDisplayNames() {
if (displayNameUpdates.isEmpty()) { return }
val hexEncodedPublicKeys = displayNameUpdates
displayNameUpdates = setOf()
OpenGroupAPI.getDisplayNames(hexEncodedPublicKeys, openGroup.server).successBackground { mapping ->
for (pair in mapping.entries) {
if (pair.key == userHexEncodedPublicKey) continue
val senderDisplayName = "${pair.value} (...${pair.key.substring(pair.key.count() - 8)})"
MessagingModuleConfiguration.shared.storage.setOpenGroupDisplayName(pair.key, openGroup.channel, openGroup.server, senderDisplayName)
}
}.fail {
displayNameUpdates = displayNameUpdates.union(hexEncodedPublicKeys)
}
}
private fun pollForDeletedMessages() {
val messagingModule = MessagingModuleConfiguration.shared
val address = GroupUtil.getEncodedOpenGroupID(openGroup.id.toByteArray())
val threadId = messagingModule.storage.getThreadIdFor(Address.fromSerialized(address)) ?: return
OpenGroupAPI.getDeletedMessageServerIDs(openGroup.channel, openGroup.server).success { deletedMessageServerIDs ->
val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { messagingModule.messageDataProvider.getMessageID(it, threadId) }
deletedMessageIDs.forEach { (messageId, isSms) ->
MessagingModuleConfiguration.shared.messageDataProvider.deleteMessage(messageId, isSms)
}
}.fail {
Log.d("Loki", "Failed to get deleted messages for group chat with ID: ${openGroup.channel} on server: ${openGroup.server}.")
}
}
private fun pollForModerators() {
OpenGroupAPI.getModerators(openGroup.channel, openGroup.server)
}
// endregion
}

View File

@@ -18,10 +18,13 @@ import java.util.concurrent.TimeUnit
class OpenGroupPollerV2(private val server: String, private val executorService: ScheduledExecutorService?) {
var hasStarted = false
var isCaughtUp = false
var secondToLastJob: MessageReceiveJob? = null
private var future: ScheduledFuture<*>? = null
companion object {
private val pollInterval: Long = 4 * 1000
const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000
}
fun startIfNeeded() {
@@ -43,6 +46,9 @@ class OpenGroupPollerV2(private val server: String, private val executorService:
val openGroupID = "$server.$room"
handleNewMessages(openGroupID, response.messages, isBackgroundPoll)
handleDeletedMessages(openGroupID, response.deletions)
if (secondToLastJob == null && !isCaughtUp) {
isCaughtUp = true
}
}
}.always {
executorService?.schedule(this@OpenGroupPollerV2::poll, OpenGroupPollerV2.pollInterval, TimeUnit.MILLISECONDS)
@@ -51,6 +57,7 @@ class OpenGroupPollerV2(private val server: String, private val executorService:
private fun handleNewMessages(openGroupID: String, messages: List<OpenGroupMessageV2>, isBackgroundPoll: Boolean) {
if (!hasStarted) { return }
var latestJob: MessageReceiveJob? = null
messages.sortedBy { it.serverID!! }.forEach { message ->
try {
val senderPublicKey = message.sender!!
@@ -66,6 +73,10 @@ class OpenGroupPollerV2(private val server: String, private val executorService:
job.executeAsync()
} else {
JobQueue.shared.add(job)
if (!isCaughtUp) {
secondToLastJob = latestJob
}
latestJob = job
}
} catch (e: Exception) {
Log.e("Loki", "Exception parsing message", e)
@@ -77,7 +88,7 @@ class OpenGroupPollerV2(private val server: String, private val executorService:
val storage = MessagingModuleConfiguration.shared.storage
val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
val threadID = storage.getThreadIdFor(Address.fromSerialized(groupID)) ?: return
val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return
val deletedMessageIDs = deletedMessageServerIDs.mapNotNull { serverID ->
val messageID = dataProvider.getMessageID(serverID, threadID)
if (messageID == null) {

View File

@@ -1,269 +0,0 @@
package org.session.libsession.messaging.utilities
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.then
import okhttp3.*
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsignal.crypto.DiffieHellman
import org.session.libsignal.streams.ProfileCipherOutputStream
import org.session.libsignal.exceptions.NonSuccessfulResponseCodeException
import org.session.libsignal.exceptions.PushNetworkException
import org.session.libsignal.streams.StreamDetails
import org.session.libsignal.utilities.ProfileAvatarData
import org.session.libsignal.utilities.PushAttachmentData
import org.session.libsignal.streams.DigestingRequestBody
import org.session.libsignal.streams.ProfileCipherOutputStreamFactory
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.*
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import java.util.*
/**
* Base class that provides utilities for .NET based APIs.
*/
open class DotNetAPI {
internal enum class HTTPVerb { GET, PUT, POST, DELETE, PATCH }
// Error
internal sealed class Error(val description: String) : Exception(description) {
object Generic : Error("An error occurred.")
object InvalidURL : Error("Invalid URL.")
object ParsingFailed : Error("Invalid file server response.")
object SigningFailed : Error("Couldn't sign message.")
object EncryptionFailed : Error("Couldn't encrypt file.")
object DecryptionFailed : Error("Couldn't decrypt file.")
object MaxFileSizeExceeded : Error("Maximum file size exceeded.")
object TokenExpired: Error("Token expired.") // Session Android
internal val isRetryable: Boolean = false
}
companion object {
private val authTokenRequestCache = hashMapOf<String, Promise<String, Exception>>()
}
public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)
fun getAuthToken(server: String): Promise<String, Exception> {
val storage = MessagingModuleConfiguration.shared.storage
val token = storage.getAuthToken(server)
if (token != null) { return Promise.of(token) }
// Avoid multiple token requests to the server by caching
var promise = authTokenRequestCache[server]
if (promise == null) {
promise = requestNewAuthToken(server).bind { submitAuthToken(it, server) }.then { newToken ->
storage.setAuthToken(server, newToken)
newToken
}.always {
authTokenRequestCache.remove(server)
}
authTokenRequestCache[server] = promise
}
return promise
}
private fun requestNewAuthToken(server: String): Promise<String, Exception> {
Log.d("Loki", "Requesting auth token for server: $server.")
val userKeyPair = MessagingModuleConfiguration.shared.storage.getUserKeyPair() ?: throw Error.Generic
val parameters: Map<String, Any> = mapOf( "pubKey" to userKeyPair.first )
return execute(HTTPVerb.GET, server, "loki/v1/get_challenge", false, parameters).map { json ->
try {
val base64EncodedChallenge = json["cipherText64"] as String
val challenge = Base64.decode(base64EncodedChallenge)
val base64EncodedServerPublicKey = json["serverPubKey64"] as String
var serverPublicKey = Base64.decode(base64EncodedServerPublicKey)
// Discard the "05" prefix if needed
if (serverPublicKey.count() == 33) {
val hexEncodedServerPublicKey = Hex.toStringCondensed(serverPublicKey)
serverPublicKey = Hex.fromStringCondensed(hexEncodedServerPublicKey.removing05PrefixIfNeeded())
}
// The challenge is prefixed by the 16 bit IV
val tokenAsData = DiffieHellman.decrypt(challenge, serverPublicKey, userKeyPair.second)
val token = tokenAsData.toString(Charsets.UTF_8)
token
} catch (exception: Exception) {
Log.d("Loki", "Couldn't parse auth token for server: $server.")
throw exception
}
}
}
private fun submitAuthToken(token: String, server: String): Promise<String, Exception> {
Log.d("Loki", "Submitting auth token for server: $server.")
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: throw Error.Generic
val parameters = mapOf( "pubKey" to userPublicKey, "token" to token )
return execute(HTTPVerb.POST, server, "loki/v1/submit_challenge", false, parameters, isJSONRequired = false).map { token }
}
internal fun execute(verb: HTTPVerb, server: String, endpoint: String, isAuthRequired: Boolean = true, parameters: Map<String, Any> = mapOf(), isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
fun execute(token: String?): Promise<Map<*, *>, Exception> {
val sanitizedEndpoint = endpoint.removePrefix("/")
var url = "$server/$sanitizedEndpoint"
if (verb == HTTPVerb.GET || verb == HTTPVerb.DELETE) {
val queryParameters = parameters.map { "${it.key}=${it.value}" }.joinToString("&")
if (queryParameters.isNotEmpty()) { url += "?$queryParameters" }
}
var request = Request.Builder().url(url)
if (isAuthRequired) {
if (token == null) { throw IllegalStateException() }
request = request.header("Authorization", "Bearer $token")
}
when (verb) {
HTTPVerb.GET -> request = request.get()
HTTPVerb.DELETE -> request = request.delete()
else -> {
val parametersAsJSON = JsonUtil.toJson(parameters)
val body = RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
when (verb) {
HTTPVerb.PUT -> request = request.put(body)
HTTPVerb.POST -> request = request.post(body)
HTTPVerb.PATCH -> request = request.patch(body)
else -> throw IllegalStateException()
}
}
}
val serverPublicKeyPromise = if (server == FileServerAPI.shared.server) Promise.of(FileServerAPI.fileServerPublicKey)
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(server)
return serverPublicKeyPromise.bind { serverPublicKey ->
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, isJSONRequired = isJSONRequired).recover { exception ->
if (exception is HTTP.HTTPRequestFailedException) {
val statusCode = exception.statusCode
if (statusCode == 401 || statusCode == 403) {
MessagingModuleConfiguration.shared.storage.setAuthToken(server, null)
throw Error.TokenExpired
}
}
throw exception
}
}
}
return if (isAuthRequired) {
getAuthToken(server).bind { execute(it) }
} else {
execute(null)
}
}
internal fun getUserProfiles(publicKeys: Set<String>, server: String, includeAnnotations: Boolean): Promise<List<Map<*, *>>, Exception> {
val parameters = mapOf( "include_user_annotations" to includeAnnotations.toInt(), "ids" to publicKeys.joinToString { "@$it" } )
return execute(HTTPVerb.GET, server, "users", parameters = parameters).map { json ->
val data = json["data"] as? List<Map<*, *>>
if (data == null) {
Log.d("Loki", "Couldn't parse user profiles for: $publicKeys from: $json.")
throw Error.ParsingFailed
}
data!! // For some reason the compiler can't infer that this can't be null at this point
}
}
internal fun setSelfAnnotation(server: String, type: String, newValue: Any?): Promise<Map<*, *>, Exception> {
val annotation = mutableMapOf<String, Any>( "type" to type )
if (newValue != null) { annotation["value"] = newValue }
val parameters = mapOf( "annotations" to listOf( annotation ) )
return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters)
}
// UPLOAD
// TODO: migrate to v2 file server
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult {
// This function mimics what Signal does in PushServiceSocket
val contentType = "application/octet-stream"
val file = DigestingRequestBody(attachment.data, attachment.outputStreamFactory, contentType, attachment.dataSize, attachment.listener)
Log.d("Loki", "File size: ${attachment.dataSize} bytes.")
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", "network.loki")
.addFormDataPart("Content-Type", contentType)
.addFormDataPart("content", UUID.randomUUID().toString(), file)
.build()
val request = Request.Builder().url("$server/files").post(body)
return upload(server, request) { json -> // Retrying is handled by AttachmentUploadJob
val data = json["data"] as? Map<*, *>
if (data == null) {
Log.e("Loki", "Couldn't parse attachment from: $json.")
throw Error.ParsingFailed
}
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
val url = data["url"] as? String
if (id == null || url == null || url.isEmpty()) {
Log.e("Loki", "Couldn't parse upload from: $json.")
throw Error.ParsingFailed
}
UploadResult(id, url, file.transmittedDigest)
}.get()
}
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
fun uploadProfilePicture(server: String, key: ByteArray, profilePicture: StreamDetails, setLastProfilePictureUpload: () -> Unit): UploadResult {
val profilePictureUploadData = ProfileAvatarData(profilePicture.stream, ProfileCipherOutputStream.getCiphertextLength(profilePicture.length), profilePicture.contentType, ProfileCipherOutputStreamFactory(key))
val file = DigestingRequestBody(profilePictureUploadData.data, profilePictureUploadData.outputStreamFactory,
profilePictureUploadData.contentType, profilePictureUploadData.dataLength, null)
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", "network.loki")
.addFormDataPart("Content-Type", "application/octet-stream")
.addFormDataPart("content", UUID.randomUUID().toString(), file)
.build()
val request = Request.Builder().url("$server/files").post(body)
return retryIfNeeded(4) {
upload(server, request) { json ->
val data = json["data"] as? Map<*, *>
if (data == null) {
Log.d("Loki", "Couldn't parse profile picture from: $json.")
throw Error.ParsingFailed
}
val id = data["id"] as? Long ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as? String)?.toLong()
val url = data["url"] as? String
if (id == null || url == null || url.isEmpty()) {
Log.d("Loki", "Couldn't parse profile picture from: $json.")
throw Error.ParsingFailed
}
setLastProfilePictureUpload()
UploadResult(id, url, file.transmittedDigest)
}
}.get()
}
@Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class)
private fun upload(server: String, request: Request.Builder, parse: (Map<*, *>) -> UploadResult): Promise<UploadResult, Exception> {
val promise: Promise<Map<*, *>, Exception>
if (server == FileServerAPI.shared.server) {
request.addHeader("Authorization", "Bearer loki")
// Uploads to the Loki File Server shouldn't include any personally identifiable information, so use a dummy auth token
promise = OnionRequestAPI.sendOnionRequest(request.build(), FileServerAPI.shared.server, FileServerAPI.fileServerPublicKey)
} else {
promise = FileServerAPI.shared.getPublicKeyForOpenGroupServer(server).bind { openGroupServerPublicKey ->
getAuthToken(server).bind { token ->
request.addHeader("Authorization", "Bearer $token")
OnionRequestAPI.sendOnionRequest(request.build(), server, openGroupServerPublicKey)
}
}
}
return promise.map { json ->
parse(json)
}.recover { exception ->
if (exception is HTTP.HTTPRequestFailedException) {
val statusCode = exception.statusCode
if (statusCode == 401 || statusCode == 403) {
MessagingModuleConfiguration.shared.storage.setAuthToken(server, null)
}
throw NonSuccessfulResponseCodeException("Request returned with status code ${exception.statusCode}.")
}
throw PushNetworkException(exception)
}
}
}
private fun Boolean.toInt(): Int { return if (this) 1 else 0 }

View File

@@ -3,6 +3,7 @@ package org.session.libsession.messaging.utilities
import android.content.Context
import org.session.libsession.R
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.utilities.ExpirationUtil
@@ -12,8 +13,9 @@ object UpdateMessageBuilder {
var message = ""
val updateData = updateMessageData.kind ?: return message
if (!isOutgoing && sender == null) return message
val storage = MessagingModuleConfiguration.shared.storage
val senderName: String = if (!isOutgoing) {
MessagingModuleConfiguration.shared.storage.getDisplayNameForRecipient(sender!!) ?: sender
storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender
} else { context.getString(R.string.MessageRecord_you) }
when (updateData) {
@@ -33,7 +35,7 @@ object UpdateMessageBuilder {
}
is UpdateMessageData.Kind.GroupMemberAdded -> {
val members = updateData.updatedMembers.joinToString(", ") {
MessagingModuleConfiguration.shared.storage.getDisplayNameForRecipient(it) ?: it
storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it
}
message = if (isOutgoing) {
context.getString(R.string.MessageRecord_you_added_s_to_the_group, members)
@@ -54,7 +56,7 @@ object UpdateMessageBuilder {
} else {
// 2nd case: you are not part of the removed members
val members = updateData.updatedMembers.joinToString(", ") {
storage.getDisplayNameForRecipient(it) ?: it
storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it
}
if (isOutgoing) {
context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members)
@@ -76,8 +78,9 @@ object UpdateMessageBuilder {
fun buildExpirationTimerMessage(context: Context, duration: Long, sender: String? = null, isOutgoing: Boolean = false): String {
if (!isOutgoing && sender == null) return ""
val storage = MessagingModuleConfiguration.shared.storage
val senderName: String? = if (!isOutgoing) {
MessagingModuleConfiguration.shared.storage.getDisplayNameForRecipient(sender!!) ?: sender
storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender
} else { context.getString(R.string.MessageRecord_you) }
return if (duration <= 0) {
if (isOutgoing) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages)
@@ -90,7 +93,8 @@ object UpdateMessageBuilder {
}
fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, sender: String? = null): String {
val senderName = MessagingModuleConfiguration.shared.storage.getDisplayNameForRecipient(sender!!) ?: sender
val storage = MessagingModuleConfiguration.shared.storage
val senderName = storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender!!
return when (kind) {
DataExtractionNotificationInfoMessage.Kind.SCREENSHOT ->
context.getString(R.string.MessageRecord_s_took_a_screenshot, senderName)

View File

@@ -6,7 +6,7 @@ import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map
import okhttp3.Request
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.utilities.AESGCM
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Base64
@@ -307,7 +307,7 @@ object OnionRequestAPI {
val url = "${guardSnode.address}:${guardSnode.port}/onion_req/v2"
val finalEncryptionResult = result.finalEncryptionResult
val onion = finalEncryptionResult.ciphertext
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPI.maxFileSize.toDouble()) {
if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerAPIV2.maxFileSize.toDouble()) {
Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
}
@Suppress("NAME_SHADOWING") val parameters = mapOf(
@@ -373,8 +373,8 @@ object OnionRequestAPI {
}
val promise = deferred.promise
promise.fail { exception ->
val path = paths.firstOrNull { it.contains(guardSnode) }
if (exception is HTTP.HTTPRequestFailedException) {
if (exception is HTTP.HTTPRequestFailedException && SnodeModule.isInitialized) {
val path = paths.firstOrNull { it.contains(guardSnode) }
fun handleUnspecificError() {
if (path == null) { return }
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0

View File

@@ -8,8 +8,10 @@ class SnodeModule(val storage: LokiAPIDatabaseProtocol, val broadcaster: Broadca
companion object {
lateinit var shared: SnodeModule
val isInitialized: Boolean get() = Companion::shared.isInitialized
fun configure(storage: LokiAPIDatabaseProtocol, broadcaster: Broadcaster) {
if (Companion::shared.isInitialized) { return }
if (isInitialized) { return }
shared = SnodeModule(storage, broadcaster)
}
}

View File

@@ -1,15 +1,9 @@
package org.session.libsession.utilities
import okhttp3.HttpUrl
import okhttp3.Request
import org.session.libsession.messaging.file_server.FileServerAPI
import org.session.libsession.messaging.file_server.FileServerAPIV2
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsignal.utilities.Log
import org.session.libsignal.messages.SignalServiceAttachment
import org.session.libsignal.exceptions.NonSuccessfulResponseCodeException
import org.session.libsignal.exceptions.PushNetworkException
import org.session.libsignal.utilities.Base64
import java.io.*
object DownloadUtilities {
@@ -18,14 +12,14 @@ object DownloadUtilities {
* Blocks the calling thread.
*/
@JvmStatic
fun downloadFile(destination: File, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
fun downloadFile(destination: File, url: String) {
val outputStream = FileOutputStream(destination) // Throws
var remainingAttempts = 4
var exception: Exception? = null
while (remainingAttempts > 0) {
remainingAttempts -= 1
try {
downloadFile(outputStream, url, maxSize, listener)
downloadFile(outputStream, url)
exception = null
break
} catch (e: Exception) {
@@ -39,66 +33,16 @@ object DownloadUtilities {
* Blocks the calling thread.
*/
@JvmStatic
fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
if (url.contains(FileServerAPIV2.SERVER) || url.contains(FileServerAPIV2.OLD_SERVER)) {
val httpUrl = HttpUrl.parse(url)!!
val fileId = httpUrl.pathSegments().last()
val useOldServer = url.contains(FileServerAPIV2.OLD_SERVER)
try {
FileServerAPIV2.download(fileId.toLong(), useOldServer).get().let {
outputStream.write(it)
}
} catch (e: Exception) {
Log.e("Loki", "Couln't download attachment due to error",e)
throw e
}
} else {
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
// because the underlying Signal logic requires these to work correctly
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
var newPrefixedHost = oldPrefixedHost
if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) {
newPrefixedHost = FileServerAPI.shared.server
}
// Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM
// → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35
val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/")
val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID"
val request = Request.Builder().url(sanitizedURL).get()
try {
val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey
else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get()
val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get()
val result = json["result"] as? String
if (result == null) {
Log.d("Loki", "Couldn't parse attachment from: $json.")
throw PushNetworkException("Missing response body.")
}
val body = Base64.decode(result)
if (body.size > maxSize) {
Log.d("Loki", "Attachment size limit exceeded.")
throw PushNetworkException("Max response size exceeded.")
}
body.inputStream().use { input ->
val buffer = ByteArray(32768)
var count = 0
var bytes = input.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
count += bytes
if (count > maxSize) {
Log.d("Loki", "Attachment size limit exceeded.")
throw PushNetworkException("Max response size exceeded.")
}
listener?.onAttachmentProgress(body.size.toLong(), count.toLong())
bytes = input.read(buffer)
}
}
} catch (e: Exception) {
Log.e("Loki", "Couldn't download attachment due to error", e)
throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e)
fun downloadFile(outputStream: OutputStream, urlAsString: String) {
val url = HttpUrl.parse(urlAsString)!!
val fileID = url.pathSegments().last()
try {
FileServerAPIV2.download(fileID.toLong()).get().let {
outputStream.write(it)
}
} catch (e: Exception) {
Log.e("Loki", "Couldn't download attachment.", e)
throw e
}
}
}

View File

@@ -36,7 +36,7 @@ object ProfilePictureUtilities {
deferred.reject(e)
}
TextSecurePreferences.setLastProfilePictureUpload(context, Date().time)
val url = "${FileServerAPIV2.SERVER}/files/$id"
val url = "${FileServerAPIV2.server}/files/$id"
TextSecurePreferences.setProfilePictureURL(context, url)
deferred.resolve(Unit)
}

View File

@@ -29,11 +29,11 @@ class SSKEnvironment(
const val NAME_PADDED_LENGTH = 26
}
fun setDisplayName(context: Context, recipient: Recipient, displayName: String)
fun setNickname(context: Context, recipient: Recipient, nickname: String?)
fun setName(context: Context, recipient: Recipient, name: String)
fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String)
fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray)
fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode)
fun updateOpenGroupProfilePicturesIfNeeded(context: Context)
}
interface MessageExpirationManagerProtocol {

View File

@@ -99,16 +99,16 @@ object TextSecurePreferences {
private const val GIF_GRID_LAYOUT = "pref_gif_grid_layout"
// region FCM
const val IS_USING_FCM = "pref_is_using_fcm"
private const val FCM_TOKEN = "pref_fcm_token"
private const val LAST_FCM_TOKEN_UPLOAD_TIME = "pref_last_fcm_token_upload_time_2"
// region Multi Device
private const val LAST_CONFIGURATION_SYNC_TIME = "pref_last_configuration_sync_time"
const val CONFIGURATION_SYNCED = "pref_configuration_synced"
private const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
private const val LAST_OPEN_DATE = "pref_last_open_date"
@JvmStatic
fun getLastConfigurationSyncTime(context: Context): Long {
return getLongPreference(context, LAST_CONFIGURATION_SYNC_TIME, 0)
@@ -764,12 +764,19 @@ object TextSecurePreferences {
fun shouldUpdateProfile(context: Context, profileUpdateTime: Long) =
profileUpdateTime > getLongPreference(context, LAST_PROFILE_UPDATE_TIME, 0)
fun hasSeenFileServerInstabilityNotification(context: Context): Boolean {
return getBooleanPreference(context, "has_seen_file_server_instability_notification", false)
fun hasPerformedContactMigration(context: Context): Boolean {
return getBooleanPreference(context, "has_performed_contact_migration", false)
}
fun setHasSeenFileServerInstabilityNotification(context: Context) {
setBooleanPreference(context, "has_seen_file_server_instability_notification", true)
fun setPerformedContactMigration(context: Context) {
setBooleanPreference(context, "has_performed_contact_migration", true)
}
fun getLastOpenTimeDate(context: Context): Long {
return getLongPreference(context, LAST_OPEN_DATE, 0)
}
fun setLastOpenDate(context: Context) {
setLongPreference(context, LAST_OPEN_DATE, System.currentTimeMillis())
}
// endregion
}

View File

@@ -0,0 +1,3 @@
package org.session.libsession.utilities
data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)

View File

@@ -26,10 +26,13 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.function.Consumer;
import com.esotericsoftware.kryo.util.Null;
import org.greenrobot.eventbus.EventBus;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.avatars.TransparentContactPhoto;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.recipients.RecipientProvider.RecipientDetails;
@@ -286,20 +289,23 @@ public class Recipient implements RecipientModifiedListener {
}
public synchronized @Nullable String getName() {
String displayName = MessagingModuleConfiguration.shared.getStorage().getDisplayName(this.address.toString());
if (displayName != null) { return displayName; }
if (this.name == null && isGroupRecipient()) {
List<String> names = new LinkedList<>();
for (Recipient recipient : participants) {
names.add(recipient.toShortString());
StorageProtocol storage = MessagingModuleConfiguration.shared.getStorage();
String sessionID = this.address.toString();
if (isGroupRecipient()) {
if (this.name == null) {
List<String> names = new LinkedList<>();
for (Recipient recipient : participants) {
names.add(recipient.toShortString());
}
return Util.join(names, ", ");
} else {
return this.name;
}
return Util.join(names, ", ");
} else {
Contact contact = storage.getContactWithSessionID(sessionID);
if (contact == null) { return sessionID; }
return contact.displayName(Contact.ContactContext.REGULAR);
}
return this.name;
}
public void setName(@Nullable String name) {

View File

@@ -1,441 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Session -->
<style name="Widget.Session.ActionBar" parent="Widget.AppCompat.Light.ActionBar.Solid">
<item name="android:background">?colorPrimary</item>
<item name="elevation">1dp</item>
<item name="titleTextStyle">@style/TextAppearance.Session.DarkActionBar.TitleTextStyle</item>
</style>
<style name="Widget.Session.ActionBar.Flat">
<item name="android:elevation">0dp</item>
<item name="elevation">0dp</item>
</style>
<style name="TextAppearance.Session.DarkActionBar.TitleTextStyle" parent="TextAppearance.AppCompat.Widget.ActionBar.Title">
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:textSize">@dimen/very_large_font_size</item>
</style>
<style name="Widget.Session.SearchView" parent="@style/Widget.AppCompat.SearchView">
<item name="closeIcon">@drawable/ic_clear</item>
</style>
<style name="ThemeOverlay.Session.AlertDialog" parent="ThemeOverlay.AppCompat.Dialog.Alert">
<item name="buttonBarNegativeButtonStyle">@style/Widget.Session.AlertDialog.NegativeButtonStyle</item>
<item name="buttonBarPositiveButtonStyle">@style/Widget.Session.AlertDialog.PositiveButtonStyle</item>
</style>
<style name="Widget.Session.BottomSheetDialog" parent="Widget.Design.BottomSheet.Modal">
<item name="android:background">@drawable/default_bottom_sheet_background</item>
</style>
<style name="Widget.Session.AlertDialog.NegativeButtonStyle" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog">
<item name="android:textColor">@color/accent</item>
</style>
<style name="Widget.Session.AlertDialog.PositiveButtonStyle" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog">
<item name="android:textColor">@color/accent</item>
</style>
<style name="Widget.Session.AppBarLayout" parent="@style/Widget.Design.AppBarLayout">
</style>
<style name="Widget.Session.TabBar" parent="Widget.AppCompat.ActionBar.TabBar">
<item name="elevation">1dp</item>
</style>
<style name="Widget.Session.TabLayout" parent="Widget.Design.TabLayout">
<item name="elevation">1dp</item>
<item name="tabIndicatorColor">?colorAccent</item>
<item name="tabSelectedTextColor">?colorAccent</item>>
<item name="tabIndicatorHeight">@dimen/accent_line_thickness</item>
<item name="tabRippleColor">@color/cell_selected</item>
<item name="tabTextAppearance">@style/TextAppearance.Session.Tab</item>
</style>
<style name="TextAppearance.Session.Tab" parent="TextAppearance.Design.Tab">
<item name="android:textSize">@dimen/medium_font_size</item>
<item name="textAllCaps">false</item>
</style>
<!-- TODO These button styles require proper background selectors for up/down visual state. -->
<style name="Widget.Session.Button.Common" parent="">
<item name="android:textAllCaps">false</item>
<item name="android:textSize">@dimen/medium_font_size</item>
<item name="android:fontFamily">sans-serif-medium</item>
<!-- Helps to get rid of nasty elevation. We want a flat style here. -->
<!-- https://stackoverflow.com/a/31003693/3802890 -->
<item name ="android:stateListAnimator">@null</item>
</style>
<style name="Widget.Session.Button.Common.ProminentFilled">
<item name="android:background">@drawable/prominent_filled_button_medium_background</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item>
</style>
<style name="Widget.Session.Button.Common.ProminentOutline">
<item name="android:background">@drawable/prominent_outline_button_medium_background</item>
<item name="android:textColor">@color/accent</item>
<item name="android:drawableTint" tools:ignore="NewApi">@color/accent</item>
</style>
<style name="Widget.Session.Button.Common.UnimportantFilled">
<item name="android:background">@drawable/unimportant_filled_button_medium_background</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item>
</style>
<style name="Widget.Session.Button.Common.UnimportantOutline">
<item name="android:background">@drawable/unimportant_outline_button_medium_background</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item>
</style>
<style name="Widget.Session.Button.Dialog" parent="">
<item name="android:textAllCaps">false</item>
<item name="android:textSize">@dimen/small_font_size</item>
<item name="android:textColor">?android:textColorPrimary</item>
</style>
<style name="Widget.Session.Button.Dialog.Unimportant">
<item name="android:background">@drawable/unimportant_dialog_button_background</item>
</style>
<style name="Widget.Session.Button.Dialog.Prominent">
<item name="android:background">@drawable/prominent_dialog_button_background</item>
<item name="android:textColor">#222</item>
</style>
<style name="Widget.Session.Button.Dialog.Destructive">
<item name="android:background">@drawable/destructive_dialog_button_background</item>
<item name="android:textColor">@android:color/white</item>
</style>
<style name="Widget.Session.EditText.Compose" parent="@style/Signal.Text.Body">
<item name="android:padding">2dp</item>
<item name="android:background">@null</item>
<item name="android:maxLines">4</item>
<item name="android:maxLength">65536</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:capitalize">sentences</item>
<item name="android:autoText">true</item>
<item name="android:gravity">center_vertical</item>
<item name="android:imeOptions">flagNoEnterAction</item>
<item name="android:inputType">textAutoCorrect|textCapSentences|textMultiLine</item>
<item name="android:contentDescription">@string/conversation_activity__compose_description</item>
<item name="android:textColorHint">?android:textColorHint</item>
<item name="android:textSize">@dimen/small_font_size</item>
<item name="android:textCursorDrawable">@drawable/session_edit_text_cursor</item>
<item name="android:textAlignment">viewStart</item>
</style>
<style name="ThemeOverlay.Session.Settings" parent="PreferenceThemeOverlay.v14.Material" >
<item name="android:textColor">@color/text</item>
<item name="android:textColorSecondary">#99FFFFFF</item>
<item name="android:textSize">@dimen/medium_font_size</item>
</style>
<style name="SessionIDTextView">
<item name="android:background">@drawable/session_id_text_view_background</item>
<item name="android:padding">@dimen/medium_spacing</item>
<item name="android:textSize">@dimen/large_font_size</item>
<item name="android:textColor">@color/text</item>
<item name="android:fontFamily">@font/space_mono_regular</item>
<item name="android:textAlignment">viewStart</item>
</style>
<style name="SessionEditText">
<item name="android:background">@drawable/session_id_text_view_background</item>
<item name="android:paddingLeft">@dimen/medium_spacing</item>
<item name="android:paddingTop">30dp</item>
<item name="android:paddingRight">@dimen/medium_spacing</item>
<item name="android:paddingBottom">30dp</item>
<item name="android:textSize">@dimen/small_font_size</item>
<item name="android:textColor">@color/text</item>
<item name="android:textCursorDrawable">@drawable/session_edit_text_cursor</item>
<item name="android:textAlignment">viewStart</item>
<item name="android:maxLines">1</item>
</style>
<style name="SmallSessionEditText">
<item name="android:background">@drawable/session_id_text_view_background</item>
<item name="android:paddingLeft">@dimen/medium_spacing</item>
<item name="android:paddingTop">@dimen/medium_spacing</item>
<item name="android:paddingRight">@dimen/medium_spacing</item>
<item name="android:paddingBottom">@dimen/medium_spacing</item>
<item name="android:textSize">@dimen/small_font_size</item>
<item name="android:textColor">@color/text</item>
<item name="android:textCursorDrawable">@drawable/session_edit_text_cursor</item>
<item name="android:textAlignment">viewStart</item>
<item name="android:maxLines">1</item>
</style>
<style name="FakeChatViewMessageBubble">
<item name="android:paddingLeft">@dimen/medium_spacing</item>
<item name="android:paddingTop">12dp</item>
<item name="android:paddingRight">@dimen/medium_spacing</item>
<item name="android:paddingBottom">12dp</item>
<item name="android:textSize">15sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
<item name="android:elevation">0dp</item>
<item name="android:textColor">?android:textColorPrimary</item>
</style>
<style name="FakeChatViewMessageBubble.Incoming">
<item name="android:background">@drawable/fake_chat_view_incoming_message_background</item>
<item name="android:elevation">10dp</item>
</style>
<style name="FakeChatViewMessageBubble.Outgoing">
<item name="android:background">@drawable/fake_chat_view_outgoing_message_background</item>
<item name="android:elevation">10dp</item>
</style>
<!-- Session -->
<style name="NoAnimation.Theme.BlackScreen" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowAnimationStyle">@null</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="NoAnimation.Theme.AppCompat.Light.DarkActionBar" parent="@style/Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowAnimationStyle">@null</item>
</style>
<style name="TextSecure.DialogActivity" parent="Theme.AppCompat.Light">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
<style name="AppCompatAlertDialogStyleLight" parent="Theme.AppCompat.Light.Dialog.Alert">
<item name="colorAccent">@color/signal_primary_dark</item>
</style>
<style name="AppCompatAlertDialogStyleDark" parent="Theme.AppCompat.Dialog.Alert">
<item name="colorAccent">@color/signal_primary</item>
<item name="android:textColor">@null</item>
</style>
<style name="AppCompatDialogStyleLight" parent="Theme.AppCompat.Light.Dialog">
<item name="colorAccent">@color/signal_primary_dark</item>
<item name="android:windowBackground">@drawable/dialog_background</item>
</style>
<style name="AppCompatDialogStyleDark" parent="Theme.AppCompat.Dialog">
<item name="colorAccent">@color/signal_primary</item>
<item name="android:windowBackground">@drawable/dialog_background</item>
<item name="android:textColor">@null</item>
</style>
<!-- ActionBar styles -->
<style name="TextSecure.DarkActionBar"
parent="@style/Widget.AppCompat.ActionBar">
<item name="background">@color/core_grey_90</item>
<item name="android:popupTheme" tools:ignore="NewApi">@style/ThemeOverlay.AppCompat.Dark</item>
<item name="elevation">1dp</item>
<item name="popupTheme">@style/ThemeOverlay.AppCompat.Dark</item>
<item name="titleTextStyle">@style/TextSecure.TitleTextStyle</item>
<item name="subtitleTextStyle">@style/TextSecure.SubtitleTextStyle</item>
<item name="android:textColorSecondary">@color/white</item>
</style>
<style name="TextSecure.LightActionBar"
parent="@style/Widget.AppCompat.ActionBar">
<item name="background">@color/textsecure_primary</item>
<item name="elevation">1dp</item>
<item name="popupTheme">@style/ThemeOverlay.AppCompat.Light</item>
<item name="titleTextStyle">@style/TextSecure.TitleTextStyle</item>
<item name="subtitleTextStyle">@style/TextSecure.SubtitleTextStyle</item>
<item name="android:textColorPrimary">@color/white</item>
<item name="android:textColorSecondary">@color/white</item>
</style>
<style name="TextSecure.LightActionBar.DarkText"
parent="TextSecure.LightActionBar">
<item name="android:textColorPrimary">@color/black</item>
<item name="android:textColorSecondary">@color/black</item>
</style>
<style name="TextSecure.FlatLightActionBar"
parent="@style/TextSecure.LightActionBar">
<item name="elevation">0dp</item>
</style>
<style name="TextSecure.DarkActionBar.TabBar"
parent="@style/Widget.AppCompat.ActionBar.TabBar">
<item name="background">@color/gray95</item>
<item name="android:background">@color/gray95</item>
<item name="elevation">4dp</item>
</style>
<style name="TextSecure.LightActionBar.TabBar"
parent="@style/Widget.AppCompat.ActionBar.TabBar">
<item name="android:background">@color/textsecure_primary</item>
<item name="background">@color/textsecure_primary</item>
<item name="android:textColorPrimary">@color/white</item>
<item name="android:textColorSecondary">#BFffffff</item>
<item name="elevation">4dp</item>
</style>
<style name="TextSecure.TitleTextStyle" parent="TextAppearance.AppCompat.Widget.ActionBar.Title">
<item name="android:textColor">@color/white</item>
<item name="android:textColorHint">#BFffffff</item>
</style>
<style name="TextSecure.SubtitleTextStyle" parent="TextAppearance.AppCompat.Widget.ActionBar.Subtitle">
<item name="android:textColor">#BFffffff</item>
</style>
<style name="NotificationText" parent="android:TextAppearance.StatusBar.EventContent">
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
<style name="NotificationTitle" parent="android:TextAppearance.StatusBar.EventContent.Title">
<item name="android:textColor">?android:attr/textColorPrimary</item>
<item name="android:textStyle">bold</item>
</style>
<style name="Registration.Description" parent="@android:style/TextAppearance">
<item name="android:textSize">16.0sp</item>
<item name="android:typeface">sans</item>
<item name="android:textStyle">normal</item>
<item name="android:gravity">left</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:shadowColor">#ffffff</item>
<item name="android:shadowDx">1.0</item>
<item name="android:shadowDy">1.0</item>
<item name="android:shadowRadius">0.0</item>
<item name="android:lineSpacingMultiplier">1.25</item>
</style>
<style name="Registration.Label" parent="@android:style/TextAppearance">
<item name="android:textSize">12.0sp</item>
<item name="android:typeface">sans</item>
<item name="android:textStyle">normal</item>
<item name="android:textColor">#ff808080</item>
<item name="android:gravity">left</item>
<item name="android:layout_gravity">left</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:shadowColor">#ffffff</item>
<item name="android:shadowDx">1.0</item>
<item name="android:shadowDy">1.0</item>
<item name="android:shadowRadius">0.0</item>
<item name="android:lineSpacingMultiplier">1.25</item>
</style>
<style name="Registration.BigLabel" parent="@style/Registration.Label">
<item name="android:textSize">20sp</item>
</style>
<style name="Registration.Constant" parent="@android:style/TextAppearance">
<item name="android:typeface">sans</item>
<item name="android:textStyle">normal</item>
<item name="android:textColor">#ff808080</item>
<item name="android:shadowColor">#ffffff</item>
<item name="android:shadowDx">1.0</item>
<item name="android:shadowDy">1.0</item>
<item name="android:shadowRadius">0.0</item>
<item name="android:lineSpacingMultiplier">1.25</item>
</style>
<!-- For Holo Light Dialog Activity Styling Emulation -->
<style name="Widget.ProgressBar.Horizontal" parent="@android:style/Widget.Holo.ProgressBar.Horizontal">
</style>
<style name="MaterialButton">
<item name="android:elevation" tools:ignore="NewApi">1dp</item>
<item name="android:translationZ" tools:ignore="NewApi">1dp</item>
<item name="android:textColor">@color/white</item>
<item name="android:textSize">12sp</item>
</style>
<style name="InfoButton" parent="@style/MaterialButton">
<item name="android:background">@drawable/info_round</item>
</style>
<style name="ErrorButton" parent="@style/MaterialButton">
<item name="android:background">@drawable/error_round</item>
</style>
<style name="AttachmentTypeLabel">
<item name="android:textColor">?android:textColorTertiary</item>
<item name="android:textSize">@dimen/small_font_size</item>
</style>
<style name="Button.Primary" parent="Base.Widget.AppCompat.Button.Colored">
<item name="colorAccent">@color/signal_primary</item>
<item name="android:textColor">@color/white</item>
</style>
<style name="Button.Borderless" parent="Base.Widget.AppCompat.Button.Borderless">
<item name="android:textColor">@color/signal_primary</item>
</style>
<style name="Button.Borderless.Registration" parent="Base.Widget.AppCompat.Button.Borderless">
<item name="android:textColor">@color/core_grey_60</item>
</style>
<!-- RedPhone -->
<!-- Buttons in the main "button row" of the in-call onscreen touch UI. -->
<!-- "Compound button" variation of InCallButton.
These buttons have the concept of two states: checked and unchecked.
(This style is just like "InCallButton" except that we also
clear out android:textOn and android:textOff, to avoid the default
text label behavior of the ToggleButton class.) -->
<style name="WebRtcCallCompoundButton">
<item name="android:layout_height">31dp</item>
<item name="android:layout_width">31dp</item>
<item name="android:textOn">@null</item>
<item name="android:textOff">@null</item>
</style>
<style name="IdentityKey">
<item name="android:fontFamily">monospace</item>
<item name="android:typeface">monospace</item>
<item name="android:textSize">17sp</item>
<item name="android:clickable">false</item>
<item name="android:focusable">false</item>
</style>
<style name="BackupPassphrase">
<item name="android:fontFamily">monospace</item>
<item name="android:typeface">monospace</item>
<item name="android:textSize">15sp</item>
<item name="android:clickable">false</item>
<item name="android:focusable">false</item>
</style>
<style name="PreferenceThemeOverlay.Fix" parent="PreferenceThemeOverlay.v14.Material">
</style>
<style name="Color1SwitchStyle">
<item name="colorControlActivated">@color/white</item>
</style>
<style name="BottomSheetActionItem">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">@dimen/action_item_height</item>
<item name="android:textSize">@dimen/medium_font_size</item>
<item name="android:drawablePadding">@dimen/drawable_padding</item>
<item name="android:padding">@dimen/normal_padding</item>
<item name="android:gravity">center_vertical</item>
<item name="android:selectable">true</item>
<item name="android:foreground">?attr/selectableItemBackground</item>
</style>
<style name="StickerPopupAnimation" parent="@android:style/Animation">
<item name="android:windowEnterAnimation">@anim/fade_in</item>
<item name="android:windowExitAnimation">@anim/fade_out</item>
</style>
</resources>

View File

@@ -1,294 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Session -->
<!-- Due to historical reasons the base theme is dark and the light one
is implemented using "notnight" type of resources. -->
<style name="Base.Theme.Session" parent="@style/Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorPrimary">@color/action_bar_background</item>
<item name="colorPrimaryDark">@color/action_bar_background</item>
<item name="colorAccent">@color/accent</item>
<item name="colorControlNormal">?android:textColorPrimary</item>
<item name="colorControlActivated">?colorAccent</item>
<item name="colorControlHighlight">?colorAccent</item>
<item name="android:textColorPrimary">@color/text</item>
<item name="android:textColorSecondary">?android:textColorPrimary</item>
<item name="android:textColorTertiary">@color/unimportant</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:textColorHint">@color/gray27</item>
<item name="android:windowBackground">@drawable/default_session_background</item>
<item name="android:colorBackground">@color/default_background_start</item>
<item name="android:navigationBarColor">@color/compose_view_background</item>
<item name="actionBarPopupTheme">@style/ThemeOverlay.AppCompat.Light</item>
<item name="actionBarWidgetTheme">@null</item>
<item name="actionBarTheme">@style/ThemeOverlay.AppCompat.DayNight.ActionBar</item>
<item name="actionBarStyle">@style/Widget.Session.ActionBar</item>
<item name="alertDialogTheme">@style/ThemeOverlay.Session.AlertDialog</item>
<item name="bottomSheetDialogTheme">@style/Theme.Session.BottomSheet</item>
<item name="preferenceTheme">@style/ThemeOverlay.Session.Settings</item>
<item name="appBarLayoutStyle">@style/Widget.Session.AppBarLayout</item>
<item name="actionBarTabBarStyle">@style/Widget.Session.TabBar</item>
<item name="statusBarBackground">@color/accent</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="windowActionModeOverlay">true</item>
<item name="actionModeCloseDrawable">@drawable/ic_baseline_clear_24</item>
<item name="actionModeBackground">@color/compose_view_background</item>
<item name="dividerVertical">@color/separator</item>
<item name="dividerHorizontal">?dividerVertical</item>
<item name="searchViewStyle">@style/Widget.Session.SearchView</item>
<!-- App specific attributes -->
<item name="ic_visibility_on">@drawable/ic_baseline_visibility_24</item>
<item name="ic_visibility_off">@drawable/ic_baseline_visibility_off_24</item>
<item name="ic_arrow_forward">@drawable/ic_baseline_arrow_forward_24</item>
<item name="dialog_background_color">@color/dialog_background</item>
<item name="media_overview_toolbar_background">@color/transparent</item>
<item name="media_overview_header_foreground">@color/text</item>
<item name="media_keyboard_button_color">@color/core_grey_25</item>\
<item name="attachment_type_selector_background">?android:windowBackground</item>
<item name="attachment_type_selector_hide_button_background">@color/gray50</item>
<item name="attachment_document_icon_small">@drawable/ic_document_small_dark</item>
<item name="attachment_document_icon_large">@drawable/ic_document_large_dark</item>
<item name="attachment_document_extension_text_color">#222</item>
<item name="home_gradient_start">#00000000</item>
<item name="home_gradient_end">#FF000000</item>
<item name="message_received_background_color">#222325</item>
<item name="message_sent_background_color">#3F4146</item>
<item name="menu_accept_icon">@drawable/ic_baseline_done_24</item>
<item name="menu_trash_icon">@drawable/ic_baseline_delete_24</item>
<item name="menu_block_icon">@drawable/ic_baseline_block_24</item>
<item name="menu_forward_icon">@drawable/ic_baseline_forward_24</item>
<item name="menu_save_icon">@drawable/ic_baseline_save_24</item>
<item name="menu_photo_library_icon">@drawable/ic_baseline_photo_library_24</item>
<item name="menu_delete_icon">@drawable/ic_baseline_delete_24</item>
<item name="menu_copy_icon">@drawable/ic_baseline_file_copy_24</item>
<item name="menu_reply_icon">@drawable/ic_baseline_reply_24</item>
<item name="menu_selectall_icon">@drawable/ic_baseline_select_all_24</item>
<item name="menu_split_icon">@drawable/ic_baseline_call_split_24</item>
<item name="menu_popup_expand">@drawable/ic_baseline_launch_24</item>
<item name="menu_info_icon">@drawable/ic_baseline_info_24</item>
<item name="conversation_emoji_toggle">@drawable/ic_emoji_filled_keyboard_24</item>
<item name="conversation_sticker_toggle">@drawable/ic_sticker_filled_keyboard_24</item>
<item name="conversation_keyboard_toggle">@drawable/ic_baseline_keyboard_24</item>
<item name="conversation_input_background">@drawable/compose_background_dark</item>
<item name="quick_camera_icon">@drawable/ic_baseline_photo_camera_24</item>
<item name="quick_mic_icon">@drawable/ic_baseline_mic_24</item>
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/accent</item>
<item name="conversation_item_audio_seek_bar_background_color">@color/text</item>
</style>
<!-- This should be the default theme for the application. -->
<style name="Theme.Session.DayNight" parent="Base.Theme.Session">
<!-- leave empty to allow overriding -->
</style>
<style name="Theme.Session.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="windowActionModeOverlay">true</item>
</style>
<style name="Theme.Session.DayNight.FlatActionBar">
<item name="actionBarStyle">@style/Widget.Session.ActionBar.Flat</item>
</style>
<!--
This is a temporary theme that is used by any activity
which doesn't have support for light theme
(like some old Signal screens or third-party libs with white only icons)
-->
<!-- TODO Refactor this to use color resources -->
<style name="Base.Theme.Session.ForceDark" parent="Theme.Session.DayNight">
<item name="colorPrimary">#171717</item>
<item name="android:textColorPrimary">#FFFFFF</item>
<item name="android:textColorSecondary">#DFFFFFFF</item>
<item name="android:textColorTertiary">#90FFFFFF</item>
<item name="colorControlNormal">?android:textColorPrimary</item>
<item name="android:colorBackground">#121212</item>
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:navigationBarColor">?android:colorBackground</item>
<item name="android:statusBarColor">@color/transparent</item>
<item name="actionBarPopupTheme">@style/ThemeOverlay.AppCompat.Dark</item>
<item name="actionBarWidgetTheme">@null</item>
<item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item>
<item name="actionBarStyle">@style/Widget.AppCompat.ActionBar</item>
<item name="conversation_emoji_toggle">@drawable/ic_emoji_filled_keyboard_24</item>
<item name="conversation_sticker_toggle">@drawable/ic_sticker_filled_keyboard_24</item>
<item name="conversation_keyboard_toggle">@drawable/ic_baseline_keyboard_24</item>
<item name="conversation_input_background">@drawable/compose_background_dark</item>
</style>
<style name="Theme.Session.ForceDark" parent="Base.Theme.Session.ForceDark">
<!-- leave empty to allow overriding -->
</style>
<style name="Theme.Session.ForceDark.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="windowActionModeOverlay">true</item>
</style>
<style name="Theme.Session.BottomSheet" parent="@style/Theme.AppCompat.DayNight.Dialog">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowAnimationStyle">@style/Animation.MaterialComponents.BottomSheetDialog</item>
<item name="bottomSheetStyle">@style/Widget.Session.BottomSheetDialog</item>
</style>
<!-- Session -->
<!-- Original Signal dark theme -->
<style name="Base.Theme.TextSecure" parent="@style/Theme.Session.DayNight">
<item name="windowActionModeOverlay">true</item>
<item name="colorPrimary">@color/action_bar_background</item>
<item name="colorPrimaryDark">@color/action_bar_background</item>
<item name="media_overview_toolbar_background">@color/transparent</item>
<item name="media_overview_header_foreground">@color/text</item>
<item name="theme_type">dark</item>
<item name="android:navigationBarColor">@color/compose_view_background</item>
<item name="attachment_document_icon_small">@drawable/ic_document_small_dark</item>
<item name="attachment_document_icon_large">@drawable/ic_document_large_dark</item>
<item name="conversation_list_item_background">@drawable/conversation_list_item_background_dark</item>
<item name="conversation_list_item_contact_color">#ffdddddd</item>
<item name="conversation_list_item_subject_color">#ffdddddd</item>
<item name="conversation_list_item_delivery_icon_color">@color/core_grey_25</item>
<item name="conversation_list_item_date_color">#ffdddddd</item>
<item name="conversation_list_item_unread_color">@color/core_white</item>
<item name="conversation_list_item_unread_background">@drawable/unread_count_background_dark</item>
<item name="conversation_list_item_divider">@drawable/conversation_list_divider_shape_dark</item>
<item name="conversation_list_toolbar_background">@color/action_bar_background</item>
<item name="conversation_list_typing_tint">@color/core_white</item>
<item name="conversation_group_member_name">#99ffffff</item>
<item name="conversation_item_bubble_background">?message_sent_background_color</item>
<item name="conversation_item_sent_text_primary_color">@color/core_grey_05</item>
<item name="conversation_item_sent_text_secondary_color">@color/core_grey_25</item>
<item name="conversation_item_sent_icon_color">@color/core_grey_25</item>
<item name="conversation_item_sent_text_indicator_tab_color">#99ffffff</item>
<item name="conversation_item_received_text_primary_color">@color/text</item>
<item name="conversation_item_received_text_secondary_color">@color/text</item>
<item name="conversation_item_sent_indicator_text_background">@drawable/conversation_item_sent_indicator_text_shape_dark</item>
<item name="conversation_item_sticky_date_background">@drawable/sticky_date_header_background_dark</item>
<item name="conversation_item_sticky_date_text_color">@color/core_grey_45</item>
<item name="conversation_item_image_outline_color">@color/transparent_white_30</item>
<item name="verification_background">@color/core_grey_95</item>
<item name="dialog_info_icon">@drawable/ic_info_outline_dark</item>
<item name="dialog_alert_icon">@drawable/ic_warning_dark</item>
<item name="device_link_item_card_background">@color/device_link_item_background_dark</item>
<item name="fab_color">@color/textsecure_primary_dark</item>
<item name="lower_right_divet">@drawable/divet_lower_right_light</item>
<item name="conversation_background">@color/loki_darkest_gray</item>
<item name="conversation_editor_background">#22ffffff</item>
<item name="conversation_editor_text_color">#ffeeeeee</item>
<item name="conversation_input_inline_attach_icon_tint">@color/core_grey_05</item>
<item name="conversation_transport_sms_indicator">@drawable/ic_arrow_up_circle_24</item>
<item name="conversation_transport_push_indicator">@drawable/ic_arrow_up_circle_24</item>
<item name="conversation_transport_popup_background">@color/black</item>
<item name="conversation_attach_camera">@drawable/ic_photo_camera_dark</item>
<item name="conversation_attach_image">@drawable/ic_image_dark</item>
<item name="conversation_attach_video">@drawable/ic_movie_creation_dark</item>
<item name="conversation_attach_sound">@drawable/ic_volume_up_dark</item>
<item name="conversation_attach_contact_info">@drawable/ic_account_box_dark</item>
<item name="conversation_attach">@drawable/ic_attach_white_24dp</item>
<item name="conversation_number_picker_text_color_normal">@color/gray13</item>
<item name="conversation_number_picker_text_color_selected">@color/white</item>
<item name="conversation_sticker_footer_text_color">@color/core_grey_25</item>
<item name="conversation_sticker_footer_icon_color">@color/core_grey_25</item>
<item name="conversation_sticker_author_color">@color/core_grey_05</item>
<item name="emoji_tab_strip_background">@color/compose_view_background</item>
<item name="emoji_tab_indicator">@color/accent</item>
<item name="emoji_tab_underline">@color/gray78</item>
<item name="emoji_tab_seperator">@color/gray70</item>
<item name="emoji_drawer_background">@color/compose_text_view_background</item>
<item name="emoji_text_color">@color/white</item>
<item name="emoji_category_recent">@drawable/ic_recent_dark_20</item>
<item name="emoji_category_people">@drawable/ic_emoji_people_dark_20</item>
<item name="emoji_category_nature">@drawable/ic_emoji_animal_dark_20</item>
<item name="emoji_category_foods">@drawable/ic_emoji_food_dark_20</item>
<item name="emoji_category_activity">@drawable/ic_emoji_activity_dark_20</item>
<item name="emoji_category_places">@drawable/ic_emoji_travel_dark_20</item>
<item name="emoji_category_objects">@drawable/ic_emoji_object_dark_20</item>
<item name="emoji_category_symbol">@drawable/ic_emoji_symbol_dark_20</item>
<item name="emoji_category_flags">@drawable/ic_emoji_flag_dark_20</item>
<item name="emoji_category_emoticons">@drawable/ic_emoji_emoticon_dark_20</item>
<item name="emoji_variation_selector_background">@drawable/emoji_variation_selector_background_dark</item>
<item name="linkpreview_background_color">@color/black</item>
<item name="linkpreview_primary_text_color">@color/text</item>
<item name="linkpreview_secondary_text_color">@color/text</item>
<item name="linkpreview_divider_color">@color/transparent</item>
<item name="conversation_icon_attach_audio">@drawable/ic_audio_dark</item>
<item name="conversation_icon_attach_video">@drawable/ic_video_dark</item>
<item name="sticker_management_icon">@drawable/sticker_button_dark</item>
<item name="sticker_management_divider_color">@color/core_grey_75</item>
<item name="sticker_management_empty_background_color">@color/core_grey_85</item>
<item name="sticker_management_action_button_color">@color/core_grey_25</item>
<item name="sticker_popup_background">@color/transparent_black_90</item>
<item name="sticker_preview_toolbar_background">@color/core_grey_95</item>
<item name="sticker_preview_status_bar_color">@color/core_grey_85</item>
<item name="sticker_view_missing_background">@drawable/sticker_missing_background_dark</item>
<item name="tooltip_default_color">@color/core_grey_75</item>
<item name="pref_icon_tint">#FFFFFF</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.Fix</item>
<item name="shared_contact_details_header_background">@color/grey_800</item>
<item name="shared_contact_details_titlebar">@color/grey_900</item>
<item name="shared_contact_item_button_color">@color/core_grey_85</item>
</style>
<style name="Theme.TextSecure.DayNight" parent="Base.Theme.TextSecure">
<!-- leave empty to allow overriding -->
</style>
<style name="Theme.TextSecure.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="windowActionModeOverlay">true</item>
</style>
<style name="Theme.TextSecure.Dialog.Rationale" parent="Theme.AppCompat.DayNight.Dialog.Alert">
<item name="android:windowBackground">@drawable/default_dialog_background</item>
</style>
<style name="Theme.TextSecure.Dialog.MediaSendProgress" parent="@android:style/Theme.Dialog">
<item name="android:background">@drawable/default_dialog_background</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>