mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-17 21:47:28 +00:00
Merge branch 'refactor' of https://github.com/RyanRory/loki-messenger-android into refactor
This commit is contained in:
commit
841e4aa493
@ -33,12 +33,36 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// Local:
|
||||
implementation project(":libsignal")
|
||||
// Remote:
|
||||
implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation 'androidx.core:core-ktx:1.3.2'
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.2.1'
|
||||
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||
testImplementation 'junit:junit:4.+'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
|
||||
|
||||
// from libsignal:
|
||||
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||
implementation "com.googlecode.libphonenumber:libphonenumber:8.10.7"
|
||||
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
|
||||
|
||||
implementation "org.whispersystems:curve25519-java:$curve25519Version"
|
||||
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||
implementation "org.threeten:threetenbp:1.3.6"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
|
||||
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||
|
||||
testImplementation "junit:junit:3.8.2"
|
||||
testImplementation "org.assertj:assertj-core:1.7.1"
|
||||
testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0"
|
||||
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package org.session.libsession.messaging
|
||||
|
||||
import org.session.libsignal.libsignal.loki.SessionResetProtocol
|
||||
import org.session.libsignal.libsignal.state.*
|
||||
import org.session.libsignal.metadata.certificate.CertificateValidator
|
||||
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol
|
||||
|
||||
class Configuration(val storage: StorageProtocol, val signalStorage: SignalProtocolStore, val sskDatabase: SharedSenderKeysDatabaseProtocol, val sessionResetImp: SessionResetProtocol, val certificateValidator: CertificateValidator) {
|
||||
companion object {
|
||||
lateinit var shared: Configuration
|
||||
|
||||
fun configure(storage: StorageProtocol,
|
||||
signalStorage: SignalProtocolStore,
|
||||
sskDatabase: SharedSenderKeysDatabaseProtocol,
|
||||
sessionResetImp: SessionResetProtocol,
|
||||
certificateValidator: CertificateValidator
|
||||
) {
|
||||
if (Companion::shared.isInitialized) { return }
|
||||
shared = Configuration(storage, signalStorage, sskDatabase, sessionResetImp, certificateValidator)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package org.session.libsession.messaging
|
||||
|
||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||
import org.session.libsession.messaging.jobs.Job
|
||||
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||
import org.session.libsession.messaging.opengroups.OpenGroup
|
||||
|
||||
import org.session.libsignal.libsignal.ecc.ECKeyPair
|
||||
import org.session.libsignal.libsignal.ecc.ECPrivateKey
|
||||
|
||||
interface StorageProtocol {
|
||||
|
||||
// General
|
||||
fun getUserPublicKey(): String?
|
||||
fun getUserKeyPair(): ECKeyPair?
|
||||
fun getUserDisplayName(): String?
|
||||
fun getUserProfileKey(): ByteArray?
|
||||
fun getUserProfilePictureURL(): String?
|
||||
|
||||
// Signal Protocol
|
||||
|
||||
fun getOrGenerateRegistrationID(): Int //TODO needs impl
|
||||
|
||||
// Shared Sender Keys
|
||||
fun getClosedGroupPrivateKey(publicKey: String): ECPrivateKey?
|
||||
fun isClosedGroup(publicKey: String): Boolean
|
||||
|
||||
// Jobs
|
||||
fun persist(job: Job)
|
||||
fun markJobAsSucceeded(job: Job)
|
||||
fun markJobAsFailed(job: Job)
|
||||
fun getAllPendingJobs(type: String): List<Job>
|
||||
fun getAttachmentUploadJob(attachmentID: String): AttachmentUploadJob?
|
||||
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
|
||||
fun resumeMessageSendJobIfNeeded(messageSendJobID: String)
|
||||
fun isJobCanceled(job: Job): Boolean
|
||||
|
||||
// Authorization
|
||||
fun getAuthToken(server: String): String?
|
||||
fun setAuthToken(server: String, newValue: String?)
|
||||
fun removeAuthToken(server: String)
|
||||
|
||||
// Open Groups
|
||||
fun getOpenGroup(threadID: String): OpenGroup?
|
||||
fun getThreadID(openGroupID: String): String?
|
||||
|
||||
// Open Group Public Keys
|
||||
fun getOpenGroupPublicKey(server: String): String?
|
||||
fun setOpenGroupPublicKey(server: String, newValue: String)
|
||||
|
||||
// Last Message Server ID
|
||||
fun getLastMessageServerID(group: Long, server: String): Long?
|
||||
fun setLastMessageServerID(group: Long, server: String, newValue: Long)
|
||||
fun removeLastMessageServerID(group: Long, server: String)
|
||||
|
||||
// Last Deletion Server ID
|
||||
fun getLastDeletionServerID(group: Long, server: String): Long?
|
||||
fun setLastDeletionServerID(group: Long, server: String, newValue: Long)
|
||||
fun removeLastDeletionServerID(group: Long, server: String)
|
||||
|
||||
// Open Group Metadata
|
||||
fun setUserCount(group: Long, server: String, newValue: Int)
|
||||
fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String)
|
||||
fun getOpenGroupProfilePictureURL(group: Long, server: String): String?
|
||||
fun updateTitle(groupID: String, newValue: String)
|
||||
fun updateProfilePicture(groupID: String, newValue: ByteArray)
|
||||
|
||||
|
||||
|
||||
|
||||
fun getSessionRequestSentTimestamp(publicKey: String): Long?
|
||||
fun setSessionRequestSentTimestamp(publicKey: String, newValue: Long)
|
||||
fun getSessionRequestProcessedTimestamp(publicKey: String): Long?
|
||||
fun setSessionRequestProcessedTimestamp(publicKey: String, newValue: Long)
|
||||
}
|
@ -0,0 +1,262 @@
|
||||
package org.session.libsession.messaging.fileserver
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.Request
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.libsignal.util.Hex
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.SnodeAPI
|
||||
import org.session.libsignal.service.loki.api.LokiDotNetAPI
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.service.loki.protocol.shelved.multidevice.DeviceLink
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.set
|
||||
|
||||
class FileServerAPI(public val server: String, userPublicKey: String, userPrivateKey: ByteArray, private val database: LokiAPIDatabaseProtocol) : LokiDotNetAPI(userPublicKey, userPrivateKey, database) {
|
||||
|
||||
companion object {
|
||||
// region Settings
|
||||
/**
|
||||
* Deprecated.
|
||||
*/
|
||||
private val deviceLinkType = "network.loki.messenger.devicemapping"
|
||||
/**
|
||||
* Deprecated.
|
||||
*/
|
||||
private val deviceLinkRequestCache = ConcurrentHashMap<String, Promise<Set<DeviceLink>, Exception>>()
|
||||
/**
|
||||
* Deprecated.
|
||||
*/
|
||||
private val deviceLinkUpdateInterval = 60 * 1000
|
||||
private val lastDeviceLinkUpdate = ConcurrentHashMap<String, Long>()
|
||||
|
||||
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 Device Link Update Result
|
||||
sealed class DeviceLinkUpdateResult {
|
||||
class Success(val publicKey: String, val deviceLinks: Set<DeviceLink>) : DeviceLinkUpdateResult()
|
||||
class Failure(val publicKey: String, val error: Exception) : DeviceLinkUpdateResult()
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region API
|
||||
public fun hasDeviceLinkCacheExpired(referenceTime: Long = System.currentTimeMillis(), publicKey: String): Boolean {
|
||||
return !lastDeviceLinkUpdate.containsKey(publicKey) || (referenceTime - lastDeviceLinkUpdate[publicKey]!! > deviceLinkUpdateInterval)
|
||||
}
|
||||
|
||||
fun getDeviceLinks(publicKey: String, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
|
||||
return Promise.of(setOf())
|
||||
/*
|
||||
if (deviceLinkRequestCache.containsKey(publicKey) && !isForcedUpdate) {
|
||||
val result = deviceLinkRequestCache[publicKey]
|
||||
if (result != null) { return result } // A request was already pending
|
||||
}
|
||||
val promise = getDeviceLinks(setOf(publicKey), isForcedUpdate)
|
||||
deviceLinkRequestCache[publicKey] = promise
|
||||
promise.always {
|
||||
deviceLinkRequestCache.remove(publicKey)
|
||||
}
|
||||
return promise
|
||||
*/
|
||||
}
|
||||
|
||||
fun getDeviceLinks(publicKeys: Set<String>, isForcedUpdate: Boolean = false): Promise<Set<DeviceLink>, Exception> {
|
||||
return Promise.of(setOf())
|
||||
/*
|
||||
val validPublicKeys = publicKeys.filter { PublicKeyValidation.isValid(it) }
|
||||
val now = System.currentTimeMillis()
|
||||
// IMPORTANT: Don't fetch device links for the current user (i.e. don't remove the it != userHexEncodedPublicKey) check below
|
||||
val updatees = validPublicKeys.filter { it != userPublicKey && (hasDeviceLinkCacheExpired(now, it) || isForcedUpdate) }.toSet()
|
||||
val cachedDeviceLinks = validPublicKeys.minus(updatees).flatMap { database.getDeviceLinks(it) }.toSet()
|
||||
if (updatees.isEmpty()) {
|
||||
return Promise.of(cachedDeviceLinks)
|
||||
} else {
|
||||
return getUserProfiles(updatees, server, true).map(SnodeAPI.sharedContext) { data ->
|
||||
data.map dataMap@ { node ->
|
||||
val publicKey = node["username"] as String
|
||||
val annotations = node["annotations"] as List<Map<*, *>>
|
||||
val deviceLinksAnnotation = annotations.find {
|
||||
annotation -> (annotation["type"] as String) == deviceLinkType
|
||||
} ?: return@dataMap DeviceLinkUpdateResult.Success(publicKey, setOf())
|
||||
val value = deviceLinksAnnotation["value"] as Map<*, *>
|
||||
val deviceLinksAsJSON = value["authorisations"] as List<Map<*, *>>
|
||||
val deviceLinks = deviceLinksAsJSON.mapNotNull { deviceLinkAsJSON ->
|
||||
try {
|
||||
val masterPublicKey = deviceLinkAsJSON["primaryDevicePubKey"] as String
|
||||
val slavePublicKey = deviceLinkAsJSON["secondaryDevicePubKey"] as String
|
||||
var requestSignature: ByteArray? = null
|
||||
var authorizationSignature: ByteArray? = null
|
||||
if (deviceLinkAsJSON["requestSignature"] != null) {
|
||||
val base64EncodedSignature = deviceLinkAsJSON["requestSignature"] as String
|
||||
requestSignature = Base64.decode(base64EncodedSignature)
|
||||
}
|
||||
if (deviceLinkAsJSON["grantSignature"] != null) {
|
||||
val base64EncodedSignature = deviceLinkAsJSON["grantSignature"] as String
|
||||
authorizationSignature = Base64.decode(base64EncodedSignature)
|
||||
}
|
||||
val deviceLink = DeviceLink(masterPublicKey, slavePublicKey, requestSignature, authorizationSignature)
|
||||
val isValid = deviceLink.verify()
|
||||
if (!isValid) {
|
||||
Log.d("Loki", "Ignoring invalid device link: $deviceLinkAsJSON.")
|
||||
return@mapNotNull null
|
||||
}
|
||||
deviceLink
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to parse device links for $publicKey from $deviceLinkAsJSON due to error: $e.")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
DeviceLinkUpdateResult.Success(publicKey, deviceLinks)
|
||||
}
|
||||
}.recover { e ->
|
||||
publicKeys.map { DeviceLinkUpdateResult.Failure(it, e) }
|
||||
}.success { updateResults ->
|
||||
for (updateResult in updateResults) {
|
||||
if (updateResult is DeviceLinkUpdateResult.Success) {
|
||||
database.clearDeviceLinks(updateResult.publicKey)
|
||||
updateResult.deviceLinks.forEach { database.addDeviceLink(it) }
|
||||
} else {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}.map(SnodeAPI.sharedContext) { updateResults ->
|
||||
val deviceLinks = mutableListOf<DeviceLink>()
|
||||
for (updateResult in updateResults) {
|
||||
when (updateResult) {
|
||||
is DeviceLinkUpdateResult.Success -> {
|
||||
lastDeviceLinkUpdate[updateResult.publicKey] = now
|
||||
deviceLinks.addAll(updateResult.deviceLinks)
|
||||
}
|
||||
is DeviceLinkUpdateResult.Failure -> {
|
||||
if (updateResult.error is SnodeAPI.Error.ParsingFailed) {
|
||||
lastDeviceLinkUpdate[updateResult.publicKey] = now // Don't infinitely update in case of a parsing failure
|
||||
}
|
||||
deviceLinks.addAll(database.getDeviceLinks(updateResult.publicKey)) // Fall back on cached device links in case of a failure
|
||||
}
|
||||
}
|
||||
}
|
||||
// Updatees that didn't show up in the response provided by the file server are assumed to not have any device links
|
||||
val excludedUpdatees = updatees.filter { updatee ->
|
||||
updateResults.find { updateResult ->
|
||||
when (updateResult) {
|
||||
is DeviceLinkUpdateResult.Success -> updateResult.publicKey == updatee
|
||||
is DeviceLinkUpdateResult.Failure -> updateResult.publicKey == updatee
|
||||
}
|
||||
} == null
|
||||
}
|
||||
excludedUpdatees.forEach {
|
||||
lastDeviceLinkUpdate[it] = now
|
||||
}
|
||||
deviceLinks.union(cachedDeviceLinks)
|
||||
}.recover {
|
||||
publicKeys.flatMap { database.getDeviceLinks(it) }.toSet()
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
fun setDeviceLinks(deviceLinks: Set<DeviceLink>): Promise<Unit, Exception> {
|
||||
return Promise.of(Unit)
|
||||
/*
|
||||
val isMaster = deviceLinks.find { it.masterPublicKey == userPublicKey } != null
|
||||
val deviceLinksAsJSON = deviceLinks.map { it.toJSON() }
|
||||
val value = if (deviceLinks.isNotEmpty()) mapOf( "isPrimary" to isMaster, "authorisations" to deviceLinksAsJSON ) else null
|
||||
val annotation = mapOf( "type" to deviceLinkType, "value" to value )
|
||||
val parameters = mapOf( "annotations" to listOf( annotation ) )
|
||||
return retryIfNeeded(maxRetryCount) {
|
||||
execute(HTTPVerb.PATCH, server, "/users/me", parameters = parameters)
|
||||
}.map { Unit }
|
||||
*/
|
||||
}
|
||||
|
||||
fun addDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
|
||||
return Promise.of(Unit)
|
||||
/*
|
||||
Log.d("Loki", "Updating device links.")
|
||||
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
|
||||
val mutableDeviceLinks = deviceLinks.toMutableSet()
|
||||
mutableDeviceLinks.add(deviceLink)
|
||||
setDeviceLinks(mutableDeviceLinks)
|
||||
}.success {
|
||||
database.addDeviceLink(deviceLink)
|
||||
}.map { Unit }
|
||||
*/
|
||||
}
|
||||
|
||||
fun removeDeviceLink(deviceLink: DeviceLink): Promise<Unit, Exception> {
|
||||
return Promise.of(Unit)
|
||||
/*
|
||||
Log.d("Loki", "Updating device links.")
|
||||
return getDeviceLinks(userPublicKey, true).bind { deviceLinks ->
|
||||
val mutableDeviceLinks = deviceLinks.toMutableSet()
|
||||
mutableDeviceLinks.remove(deviceLink)
|
||||
setDeviceLinks(mutableDeviceLinks)
|
||||
}.success {
|
||||
database.removeDeviceLink(deviceLink)
|
||||
}.map { Unit }
|
||||
*/
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,17 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
class AttachmentDownloadJob: Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
|
||||
// Settings
|
||||
override val maxFailureCount: Int = 100
|
||||
companion object {
|
||||
val collection: String = "AttachmentDownloadJobCollection"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,17 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
class AttachmentUploadJob : Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
|
||||
// Settings
|
||||
override val maxFailureCount: Int = 20
|
||||
companion object {
|
||||
val collection: String = "AttachmentUploadJobCollection"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,11 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
interface Job {
|
||||
var delegate: JobDelegate?
|
||||
var id: String?
|
||||
var failureCount: Int
|
||||
|
||||
val maxFailureCount: Int
|
||||
|
||||
fun execute()
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
interface JobDelegate {
|
||||
fun handleJobSucceeded(job: Job)
|
||||
fun handleJobFailed(job: Job, error: Exception)
|
||||
fun handleJobFailedPermanently(job: Job, error: Exception)
|
||||
}
|
@ -1,4 +1,89 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import java.util.Timer
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
class JobQueue : JobDelegate {
|
||||
private var hasResumedPendingJobs = false // Just for debugging
|
||||
|
||||
companion object {
|
||||
val shared: JobQueue by lazy { JobQueue() }
|
||||
}
|
||||
|
||||
fun add(job: Job) {
|
||||
addWithoutExecuting(job)
|
||||
job.execute()
|
||||
}
|
||||
|
||||
fun addWithoutExecuting(job: Job) {
|
||||
job.id = System.currentTimeMillis().toString()
|
||||
Configuration.shared.storage.persist(job)
|
||||
job.delegate = this
|
||||
}
|
||||
|
||||
fun resumePendingJobs() {
|
||||
if (hasResumedPendingJobs) {
|
||||
Log.d("Loki", "resumePendingJobs() should only be called once.")
|
||||
return
|
||||
}
|
||||
hasResumedPendingJobs = true
|
||||
val allJobTypes = listOf(AttachmentDownloadJob.collection, AttachmentDownloadJob.collection, MessageReceiveJob.collection, MessageSendJob.collection, NotifyPNServerJob.collection)
|
||||
allJobTypes.forEach { type ->
|
||||
val allPendingJobs = Configuration.shared.storage.getAllPendingJobs(type)
|
||||
allPendingJobs.sortedBy { it.id }.forEach { job ->
|
||||
Log.i("Jobs", "Resuming pending job of type: ${job::class.simpleName}.")
|
||||
job.delegate = this
|
||||
job.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleJobSucceeded(job: Job) {
|
||||
Configuration.shared.storage.markJobAsSucceeded(job)
|
||||
}
|
||||
|
||||
override fun handleJobFailed(job: Job, error: Exception) {
|
||||
job.failureCount += 1
|
||||
val storage = Configuration.shared.storage
|
||||
if (storage.isJobCanceled(job)) { return Log.i("Jobs", "${job::class.simpleName} canceled.")}
|
||||
storage.persist(job)
|
||||
if (job.failureCount == job.maxFailureCount) {
|
||||
storage.markJobAsFailed(job)
|
||||
} else {
|
||||
val retryInterval = getRetryInterval(job)
|
||||
Log.i("Jobs", "${job::class.simpleName} failed; scheduling retry (failure count is ${job.failureCount}).")
|
||||
Timer().schedule(delay = retryInterval) {
|
||||
Log.i("Jobs", "Retrying ${job::class.simpleName}.")
|
||||
job.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleJobFailedPermanently(job: Job, error: Exception) {
|
||||
job.failureCount += 1
|
||||
val storage = Configuration.shared.storage
|
||||
storage.persist(job)
|
||||
storage.markJobAsFailed(job)
|
||||
}
|
||||
|
||||
private fun getRetryInterval(job: Job): Long {
|
||||
// Arbitrary backoff factor...
|
||||
// try 1 delay: 0ms
|
||||
// try 2 delay: 190ms
|
||||
// ...
|
||||
// try 5 delay: 1300ms
|
||||
// ...
|
||||
// try 11 delay: 61310ms
|
||||
val backoffFactor = 1.9
|
||||
val maxBackoff = (60 * 60 * 1000).toDouble()
|
||||
return (100 * min(maxBackoff, backoffFactor.pow(job.failureCount))).roundToLong()
|
||||
}
|
||||
}
|
@ -1,4 +1,17 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
class MessageReceiveJob : Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
|
||||
// Settings
|
||||
override val maxFailureCount: Int = 10
|
||||
companion object {
|
||||
val collection: String = "MessageReceiveJobCollection"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,17 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
class MessageSendJob : Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
|
||||
// Settings
|
||||
override val maxFailureCount: Int = 10
|
||||
companion object {
|
||||
val collection: String = "MessageSendJobCollection"
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,57 @@
|
||||
package org.session.messaging.jobs
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
class NotifyPNServerJob : Job {
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
|
||||
import org.session.libsession.snode.SnodeMessage
|
||||
import org.session.libsession.snode.OnionRequestAPI
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
|
||||
class NotifyPNServerJob(val message: SnodeMessage) : Job {
|
||||
override var delegate: JobDelegate? = null
|
||||
override var id: String? = null
|
||||
override var failureCount: Int = 0
|
||||
|
||||
// Settings
|
||||
override val maxFailureCount: Int = 20
|
||||
companion object {
|
||||
val collection: String = "NotifyPNServerJobCollection"
|
||||
}
|
||||
|
||||
// Running
|
||||
override fun execute() {
|
||||
val server = PushNotificationAPI.server
|
||||
val parameters = mapOf( "data" to message.data, "send_to" to message.recipient )
|
||||
val url = "${server}/notify"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(4) {
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code == null || code == 0) {
|
||||
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "[Loki] Couldn't notify PN server due to error: $exception.")
|
||||
}
|
||||
}.success {
|
||||
handleSuccess()
|
||||
}. fail {
|
||||
handleFailure(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
delegate?.handleJobSucceeded(this)
|
||||
}
|
||||
|
||||
private fun handleFailure(error: Exception) {
|
||||
delegate?.handleJobFailed(this, error)
|
||||
}
|
||||
}
|
@ -1,5 +1,12 @@
|
||||
package org.session.messaging.messages
|
||||
package org.session.libsession.messaging.messages
|
||||
|
||||
enum class Destination {
|
||||
sealed class Destination {
|
||||
|
||||
class Contact(val publicKey: String) : Destination()
|
||||
class ClosedGroup(val groupPublicKey: String) : Destination()
|
||||
class OpenGroup(val channel: Long, val server: String) : Destination()
|
||||
|
||||
companion object {
|
||||
//TODO need to implement the equivalent to TSThread and then implement from(...)
|
||||
}
|
||||
}
|
@ -1,5 +1,26 @@
|
||||
package org.session.messaging.messages
|
||||
package org.session.libsession.messaging.messages
|
||||
|
||||
open class Message {
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
abstract class Message {
|
||||
|
||||
var id: String? = null
|
||||
var threadID: String? = null
|
||||
var sentTimestamp: Long? = null
|
||||
var receivedTimestamp: Long? = null
|
||||
var recipient: String? = null
|
||||
var sender: String? = null
|
||||
var groupPublicKey: String? = null
|
||||
var openGroupServerMessageID: Long? = null
|
||||
val ttl: Long = 2 * 24 * 60 * 60 * 1000
|
||||
|
||||
// validation
|
||||
open fun isValid(): Boolean {
|
||||
sentTimestamp = if (sentTimestamp!! > 0) sentTimestamp else return false
|
||||
receivedTimestamp = if (receivedTimestamp!! > 0) receivedTimestamp else return false
|
||||
return sender != null && recipient != null
|
||||
}
|
||||
|
||||
abstract fun toProto(): SignalServiceProtos.Content?
|
||||
|
||||
}
|
@ -1,4 +1,154 @@
|
||||
package org.session.messaging.messages.control
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey
|
||||
|
||||
class ClosedGroupUpdate() : ControlMessage() {
|
||||
|
||||
var kind: Kind? = null
|
||||
|
||||
// Kind enum
|
||||
sealed class Kind {
|
||||
class New(val groupPublicKey: ByteArray, val name: String, val groupPrivateKey: ByteArray, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
||||
class Info(val groupPublicKey: ByteArray, val name: String, val senderKeys: Collection<org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey>, val members: Collection<ByteArray>, val admins: Collection<ByteArray>) : Kind()
|
||||
class SenderKeyRequest(val groupPublicKey: ByteArray) : Kind()
|
||||
class SenderKey(val groupPublicKey: ByteArray, val senderKey: org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey) : Kind()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "ClosedGroupUpdate"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupUpdate? {
|
||||
val closedGroupUpdateProto = proto.dataMessage?.closedGroupUpdate ?: return null
|
||||
val groupPublicKey = closedGroupUpdateProto.groupPublicKey
|
||||
var kind: Kind
|
||||
when(closedGroupUpdateProto.type) {
|
||||
SignalServiceProtos.ClosedGroupUpdate.Type.NEW -> {
|
||||
val name = closedGroupUpdateProto.name ?: return null
|
||||
val groupPrivateKey = closedGroupUpdateProto.groupPrivateKey ?: return null
|
||||
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
|
||||
kind = Kind.New(
|
||||
groupPublicKey = groupPublicKey.toByteArray(),
|
||||
name = name,
|
||||
groupPrivateKey = groupPrivateKey.toByteArray(),
|
||||
senderKeys = senderKeys,
|
||||
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
|
||||
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
|
||||
)
|
||||
}
|
||||
SignalServiceProtos.ClosedGroupUpdate.Type.INFO -> {
|
||||
val name = closedGroupUpdateProto.name ?: return null
|
||||
val senderKeys = closedGroupUpdateProto.senderKeysList.map { ClosedGroupSenderKey.fromProto(it) }
|
||||
kind = Kind.Info(
|
||||
groupPublicKey = groupPublicKey.toByteArray(),
|
||||
name = name,
|
||||
senderKeys = senderKeys,
|
||||
members = closedGroupUpdateProto.membersList.map { it.toByteArray() },
|
||||
admins = closedGroupUpdateProto.adminsList.map { it.toByteArray() }
|
||||
)
|
||||
}
|
||||
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST -> {
|
||||
kind = Kind.SenderKeyRequest(groupPublicKey = groupPublicKey.toByteArray())
|
||||
}
|
||||
SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY -> {
|
||||
val senderKeyProto = closedGroupUpdateProto.senderKeysList?.first() ?: return null
|
||||
kind = Kind.SenderKey(
|
||||
groupPublicKey = groupPublicKey.toByteArray(),
|
||||
senderKey = ClosedGroupSenderKey.fromProto(senderKeyProto)
|
||||
)
|
||||
}
|
||||
}
|
||||
return ClosedGroupUpdate(kind)
|
||||
}
|
||||
}
|
||||
|
||||
// constructor
|
||||
internal constructor(kind: Kind?) : this() {
|
||||
this.kind = kind
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
val kind = kind ?: return false
|
||||
when(kind) {
|
||||
is Kind.New -> {
|
||||
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.groupPrivateKey.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
|
||||
}
|
||||
is Kind.Info -> {
|
||||
return !kind.groupPublicKey.isEmpty() && !kind.name.isEmpty() && !kind.members.isEmpty() && !kind.admins.isEmpty()
|
||||
}
|
||||
is Kind.SenderKeyRequest -> {
|
||||
return !kind.groupPublicKey.isEmpty()
|
||||
}
|
||||
is Kind.SenderKey -> {
|
||||
return !kind.groupPublicKey.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val kind = kind
|
||||
if (kind == null) {
|
||||
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
|
||||
return null
|
||||
}
|
||||
try {
|
||||
val closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdate.Builder = SignalServiceProtos.ClosedGroupUpdate.newBuilder()
|
||||
when (kind) {
|
||||
is Kind.New -> {
|
||||
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.NEW
|
||||
closedGroupUpdate.name = kind.name
|
||||
closedGroupUpdate.groupPrivateKey = ByteString.copyFrom(kind.groupPrivateKey)
|
||||
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
|
||||
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
|
||||
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
|
||||
}
|
||||
is Kind.Info -> {
|
||||
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.INFO
|
||||
closedGroupUpdate.name = kind.name
|
||||
closedGroupUpdate.addAllSenderKeys(kind.senderKeys.map { it.toProto() })
|
||||
closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) })
|
||||
closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) })
|
||||
}
|
||||
is Kind.SenderKeyRequest -> {
|
||||
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY_REQUEST
|
||||
}
|
||||
is Kind.SenderKey -> {
|
||||
closedGroupUpdate.groupPublicKey = ByteString.copyFrom(kind.groupPublicKey)
|
||||
closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdate.Type.SENDER_KEY
|
||||
closedGroupUpdate.addAllSenderKeys(listOf( kind.senderKey.toProto() ))
|
||||
}
|
||||
}
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||
dataMessageProto.closedGroupUpdate = closedGroupUpdate.build()
|
||||
contentProto.dataMessage = dataMessageProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct closed group update proto from: $this")
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class ClosedGroupUpdate : ControlMessage() {
|
||||
}
|
||||
|
||||
// extension functions to class ClosedGroupSenderKey
|
||||
|
||||
private fun ClosedGroupSenderKey.Companion.fromProto(proto: SignalServiceProtos.ClosedGroupUpdate.SenderKey): ClosedGroupSenderKey {
|
||||
return ClosedGroupSenderKey(chainKey = proto.chainKey.toByteArray(), keyIndex = proto.keyIndex, publicKey = proto.publicKey.toByteArray())
|
||||
}
|
||||
|
||||
private fun ClosedGroupSenderKey.toProto(): SignalServiceProtos.ClosedGroupUpdate.SenderKey {
|
||||
val proto = SignalServiceProtos.ClosedGroupUpdate.SenderKey.newBuilder()
|
||||
proto.chainKey = ByteString.copyFrom(chainKey)
|
||||
proto.keyIndex = keyIndex
|
||||
proto.publicKey = ByteString.copyFrom(publicKey)
|
||||
return proto.build()
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
package org.session.messaging.messages.control
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
import org.session.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
|
||||
open class ControlMessage : Message() {
|
||||
abstract class ControlMessage : Message() {
|
||||
}
|
@ -1,4 +1,51 @@
|
||||
package org.session.messaging.messages.control
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
class ExpirationTimerUpdate : ControlMessage() {
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class ExpirationTimerUpdate() : ControlMessage() {
|
||||
|
||||
var duration: Int? = 0
|
||||
|
||||
companion object {
|
||||
const val TAG = "ExpirationTimerUpdate"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? {
|
||||
val dataMessageProto = proto.dataMessage ?: return null
|
||||
val isExpirationTimerUpdate = (dataMessageProto.flags and SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0 //TODO validate that 'and' operator equivalent to Swift '&'
|
||||
if (!isExpirationTimerUpdate) return null
|
||||
val duration = dataMessageProto.expireTimer
|
||||
return ExpirationTimerUpdate(duration)
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(duration: Int) : this() {
|
||||
this.duration = duration
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
return duration != null
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val duration = duration
|
||||
if (duration == null) {
|
||||
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
|
||||
return null
|
||||
}
|
||||
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||
dataMessageProto.flags = SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE
|
||||
dataMessageProto.expireTimer = duration
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
try {
|
||||
contentProto.dataMessage = dataMessageProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct expiration timer update proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,53 @@
|
||||
package org.session.messaging.messages.control
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
class ReadReceipt : ControlMessage() {
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class ReadReceipt() : ControlMessage() {
|
||||
|
||||
var timestamps: LongArray? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReadReceipt"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): ReadReceipt? {
|
||||
val receiptProto = proto.receiptMessage ?: return null
|
||||
if (receiptProto.type != SignalServiceProtos.ReceiptMessage.Type.READ) return null
|
||||
val timestamps = receiptProto.timestampList
|
||||
if (timestamps.isEmpty()) return null
|
||||
return ReadReceipt(timestamps = timestamps.toLongArray())
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(timestamps: LongArray?) : this() {
|
||||
this.timestamps = timestamps
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
val timestamps = timestamps ?: return false
|
||||
if (timestamps.isNotEmpty()) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val timestamps = timestamps
|
||||
if (timestamps == null) {
|
||||
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
|
||||
return null
|
||||
}
|
||||
val receiptProto = SignalServiceProtos.ReceiptMessage.newBuilder()
|
||||
receiptProto.type = SignalServiceProtos.ReceiptMessage.Type.READ
|
||||
receiptProto.addAllTimestamp(timestamps.asIterable())
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
try {
|
||||
contentProto.receiptMessage = receiptProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(ExpirationTimerUpdate.TAG, "Couldn't construct read receipt proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,75 @@
|
||||
package org.session.messaging.messages.control
|
||||
package org.session.libsession.messaging.messages.control
|
||||
|
||||
class TypingIndicator : ControlMessage() {
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class TypingIndicator() : ControlMessage() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "TypingIndicator"
|
||||
|
||||
//val ttl: 30 * 1000 //TODO
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): TypingIndicator? {
|
||||
val typingIndicatorProto = proto.typingMessage ?: return null
|
||||
val kind = Kind.fromProto(typingIndicatorProto.action)
|
||||
return TypingIndicator(kind = kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Kind enum
|
||||
enum class Kind {
|
||||
STARTED,
|
||||
STOPPED,
|
||||
;
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromProto(proto: SignalServiceProtos.TypingMessage.Action): Kind =
|
||||
when (proto) {
|
||||
SignalServiceProtos.TypingMessage.Action.STARTED -> STARTED
|
||||
SignalServiceProtos.TypingMessage.Action.STOPPED -> STOPPED
|
||||
}
|
||||
}
|
||||
|
||||
fun toProto(): SignalServiceProtos.TypingMessage.Action {
|
||||
when (this) {
|
||||
STARTED -> return SignalServiceProtos.TypingMessage.Action.STARTED
|
||||
STOPPED -> return SignalServiceProtos.TypingMessage.Action.STOPPED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var kind: Kind? = null
|
||||
|
||||
//constructor
|
||||
internal constructor(kind: Kind) : this() {
|
||||
this.kind = kind
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
return kind != null
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val timestamp = sentTimestamp
|
||||
val kind = kind
|
||||
if (timestamp == null || kind == null) {
|
||||
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
|
||||
return null
|
||||
}
|
||||
val typingIndicatorProto = SignalServiceProtos.TypingMessage.newBuilder()
|
||||
typingIndicatorProto.timestamp = timestamp
|
||||
typingIndicatorProto.action = kind.toProto()
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
try {
|
||||
contentProto.typingMessage = typingIndicatorProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct typing indicator proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,35 @@
|
||||
package org.session.messaging.messages.control.unused
|
||||
package org.session.libsession.messaging.messages.control.unused
|
||||
|
||||
import org.session.messaging.messages.control.ControlMessage
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsession.messaging.messages.control.ControlMessage
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import java.security.SecureRandom
|
||||
|
||||
class NullMessage : ControlMessage() {
|
||||
class NullMessage() : ControlMessage() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "NullMessage"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): NullMessage? {
|
||||
if (proto.nullMessage == null) return null
|
||||
return NullMessage()
|
||||
}
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
|
||||
val sr = SecureRandom()
|
||||
val paddingSize = sr.nextInt(512)
|
||||
val padding = ByteArray(paddingSize)
|
||||
nullMessageProto.padding = ByteString.copyFrom(padding)
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
try {
|
||||
contentProto.nullMessage = nullMessageProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct null message proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,83 @@
|
||||
package org.session.messaging.messages.control.unused
|
||||
package org.session.libsession.messaging.messages.control.unused
|
||||
|
||||
import org.session.messaging.messages.control.ControlMessage
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsession.messaging.messages.control.ControlMessage
|
||||
import org.session.libsignal.libsignal.IdentityKey
|
||||
import org.session.libsignal.libsignal.ecc.DjbECPublicKey
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.libsignal.state.PreKeyBundle
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import java.security.SecureRandom
|
||||
|
||||
class SessionRequest : ControlMessage() {
|
||||
class SessionRequest() : ControlMessage() {
|
||||
|
||||
var preKeyBundle: PreKeyBundle? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "SessionRequest"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): SessionRequest? {
|
||||
if (proto.nullMessage == null) return null
|
||||
val preKeyBundleProto = proto.preKeyBundleMessage ?: return null
|
||||
var registrationID: Int = 0
|
||||
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID() //TODO no implementation for getOrGenerateRegistrationID yet
|
||||
//TODO just confirm if the above code does the equivalent to swift below:
|
||||
/*iOS code: Configuration.shared.storage.with { transaction in
|
||||
registrationID = Configuration.shared.storage.getOrGenerateRegistrationID(using: transaction)
|
||||
}*/
|
||||
val preKeyBundle = PreKeyBundle(
|
||||
registrationID,
|
||||
1,
|
||||
preKeyBundleProto.preKeyId,
|
||||
DjbECPublicKey(preKeyBundleProto.preKey.toByteArray()),
|
||||
preKeyBundleProto.signedKeyId,
|
||||
DjbECPublicKey(preKeyBundleProto.signedKey.toByteArray()),
|
||||
preKeyBundleProto.signature.toByteArray(),
|
||||
IdentityKey(DjbECPublicKey(preKeyBundleProto.identityKey.toByteArray()))
|
||||
)
|
||||
return SessionRequest(preKeyBundle)
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(preKeyBundle: PreKeyBundle) : this() {
|
||||
this.preKeyBundle = preKeyBundle
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
return preKeyBundle != null
|
||||
}
|
||||
|
||||
override fun toProto(): SignalServiceProtos.Content? {
|
||||
val preKeyBundle = preKeyBundle
|
||||
if (preKeyBundle == null) {
|
||||
Log.w(TAG, "Couldn't construct session request proto from: $this")
|
||||
return null
|
||||
}
|
||||
val nullMessageProto = SignalServiceProtos.NullMessage.newBuilder()
|
||||
val sr = SecureRandom()
|
||||
val paddingSize = sr.nextInt(512)
|
||||
val padding = ByteArray(paddingSize)
|
||||
nullMessageProto.padding = ByteString.copyFrom(padding)
|
||||
val preKeyBundleProto = SignalServiceProtos.PreKeyBundleMessage.newBuilder()
|
||||
preKeyBundleProto.identityKey = ByteString.copyFrom(preKeyBundle.identityKey.publicKey.serialize())
|
||||
preKeyBundleProto.deviceId = preKeyBundle.deviceId
|
||||
preKeyBundleProto.preKeyId = preKeyBundle.preKeyId
|
||||
preKeyBundleProto.preKey = ByteString.copyFrom(preKeyBundle.preKey.serialize())
|
||||
preKeyBundleProto.signedKeyId = preKeyBundle.signedPreKeyId
|
||||
preKeyBundleProto.signedKey = ByteString.copyFrom(preKeyBundle.signedPreKey.serialize())
|
||||
preKeyBundleProto.signature = ByteString.copyFrom(preKeyBundle.signedPreKeySignature)
|
||||
val contentProto = SignalServiceProtos.Content.newBuilder()
|
||||
try {
|
||||
contentProto.nullMessage = nullMessageProto.build()
|
||||
contentProto.preKeyBundleMessage = preKeyBundleProto.build()
|
||||
return contentProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct session request proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
import android.util.Size
|
||||
import android.webkit.MimeTypeMap
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import java.io.File
|
||||
|
||||
class Attachment : VisibleMessageProto<SignalServiceProtos.AttachmentPointer?>() {
|
||||
|
||||
var fileName: String? = null
|
||||
var contentType: String? = null
|
||||
var key: ByteArray? = null
|
||||
var digest: ByteArray? = null
|
||||
var kind: Kind? = null
|
||||
var caption: String? = null
|
||||
var size: Size? = null
|
||||
var sizeInBytes: Int? = 0
|
||||
var url: String? = null
|
||||
|
||||
companion object {
|
||||
fun fromProto(proto: SignalServiceProtos.AttachmentPointer): Attachment? {
|
||||
val result = Attachment()
|
||||
result.fileName = proto.fileName
|
||||
fun inferContentType(): String {
|
||||
val fileName = result.fileName ?: return "application/octet-stream" //TODO find equivalent to OWSMimeTypeApplicationOctetStream
|
||||
val fileExtension = File(fileName).extension
|
||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||
return mimeTypeMap.getMimeTypeFromExtension(fileExtension) ?: "application/octet-stream" //TODO check that it's correct
|
||||
}
|
||||
result.contentType = proto.contentType ?: inferContentType()
|
||||
result.key = proto.key.toByteArray()
|
||||
result.digest = proto.digest.toByteArray()
|
||||
val kind: Kind
|
||||
if (proto.hasFlags() && (proto.flags and SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { //TODO validate that 'and' operator = swift '&'
|
||||
kind = Kind.VOICEMESSAGE
|
||||
} else {
|
||||
kind = Kind.GENERIC
|
||||
}
|
||||
result.kind = kind
|
||||
result.caption = if (proto.hasCaption()) proto.caption else null
|
||||
val size: Size
|
||||
if (proto.hasWidth() && proto.width > 0 && proto.hasHeight() && proto.height > 0) {
|
||||
size = Size(proto.width, proto.height)
|
||||
} else {
|
||||
size = Size(0,0) //TODO check that it's equivalent to swift: CGSize.zero
|
||||
}
|
||||
result.size = size
|
||||
result.sizeInBytes = if (proto.size > 0) proto.size else null
|
||||
result. url = proto.url
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum class Kind {
|
||||
VOICEMESSAGE,
|
||||
GENERIC
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
// key and digest can be nil for open group attachments
|
||||
return (contentType != null && kind != null && size != null && sizeInBytes != null && url != null)
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.AttachmentPointer? {
|
||||
TODO("Not implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,16 @@
|
||||
package org.session.messaging.messages.visible
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
internal class Contact {
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class Contact : VisibleMessageProto<SignalServiceProtos.DataMessage.Contact?>() {
|
||||
|
||||
companion object {
|
||||
fun fromProto(proto: SignalServiceProtos.Content): Contact? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Contact? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
@ -1,4 +1,57 @@
|
||||
package org.session.messaging.messages.visible
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
internal class LinkPreview {
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class LinkPreview() : VisibleMessageProto<SignalServiceProtos.DataMessage.Preview?>(){
|
||||
|
||||
var title: String? = null
|
||||
var url: String? = null
|
||||
var attachmentID: String? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "LinkPreview"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.DataMessage.Preview): LinkPreview? {
|
||||
val title = proto.title
|
||||
val url = proto.url
|
||||
return LinkPreview(title, url, null)
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(title: String?, url: String, attachmentID: String?) : this() {
|
||||
this.title = title
|
||||
this.url = url
|
||||
this.attachmentID = attachmentID
|
||||
}
|
||||
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
return (title != null && url != null && attachmentID != null)
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Preview? {
|
||||
val url = url
|
||||
if (url == null) {
|
||||
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||
return null
|
||||
}
|
||||
val linkPreviewProto = SignalServiceProtos.DataMessage.Preview.newBuilder()
|
||||
linkPreviewProto.url = url
|
||||
title?.let { linkPreviewProto.title = title }
|
||||
val attachmentID = attachmentID
|
||||
attachmentID?.let {
|
||||
//TODO database stuff
|
||||
}
|
||||
// Build
|
||||
try {
|
||||
return linkPreviewProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,64 @@
|
||||
package org.session.messaging.messages.visible
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
internal class Profile {
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class Profile() : VisibleMessageProto<SignalServiceProtos.DataMessage?>() {
|
||||
|
||||
var displayName: String? = null
|
||||
var profileKey: ByteArray? = null
|
||||
var profilePictureURL: String? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "Profile"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.DataMessage): Profile? {
|
||||
val profileProto = proto.profile ?: return null
|
||||
val displayName = profileProto.displayName ?: return null
|
||||
val profileKey = proto.profileKey
|
||||
val profilePictureURL = profileProto.profilePictureURL
|
||||
profileKey?.let {
|
||||
val profilePictureURL = profilePictureURL
|
||||
profilePictureURL?.let {
|
||||
return Profile(displayName = displayName, profileKey = profileKey.toByteArray(), profilePictureURL = profilePictureURL)
|
||||
}
|
||||
}
|
||||
return Profile(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() {
|
||||
this.displayName = displayName
|
||||
this.profileKey = profileKey
|
||||
this.profilePictureURL = profilePictureURL
|
||||
}
|
||||
|
||||
fun toSSProto(): SignalServiceProtos.DataMessage? {
|
||||
return this.toProto("")
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.DataMessage? {
|
||||
val displayName = displayName
|
||||
if (displayName == null) {
|
||||
Log.w(TAG, "Couldn't construct link preview proto from: $this")
|
||||
return null
|
||||
}
|
||||
val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder()
|
||||
val profileProto = SignalServiceProtos.LokiUserProfile.newBuilder()
|
||||
profileProto.displayName = displayName
|
||||
val profileKey = profileKey
|
||||
profileKey?.let { dataMessageProto.profileKey = ByteString.copyFrom(profileKey) }
|
||||
val profilePictureURL = profilePictureURL
|
||||
profilePictureURL?.let { profileProto.profilePictureURL = profilePictureURL }
|
||||
// Build
|
||||
try {
|
||||
dataMessageProto.profile = profileProto.build()
|
||||
return dataMessageProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct profile proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,72 @@
|
||||
package org.session.messaging.messages.visible
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
internal class Quote {
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class Quote() : VisibleMessageProto<SignalServiceProtos.DataMessage.Quote?>() {
|
||||
|
||||
var timestamp: Long? = 0
|
||||
var publicKey: String? = null
|
||||
var text: String? = null
|
||||
var attachmentID: String? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "Quote"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.DataMessage.Quote): Quote? {
|
||||
val timestamp = proto.id
|
||||
val publicKey = proto.author
|
||||
val text = proto.text
|
||||
return Quote(timestamp, publicKey, text, null)
|
||||
}
|
||||
}
|
||||
|
||||
//constructor
|
||||
internal constructor(timestamp: Long, publicKey: String, text: String?, attachmentID: String?) : this() {
|
||||
this.timestamp = timestamp
|
||||
this.publicKey = publicKey
|
||||
this.text = text
|
||||
this.attachmentID = attachmentID
|
||||
}
|
||||
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
return (timestamp != null && publicKey != null)
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.DataMessage.Quote? {
|
||||
val timestamp = timestamp
|
||||
val publicKey = publicKey
|
||||
if (timestamp == null || publicKey == null) {
|
||||
Log.w(TAG, "Couldn't construct quote proto from: $this")
|
||||
return null
|
||||
}
|
||||
val quoteProto = SignalServiceProtos.DataMessage.Quote.newBuilder()
|
||||
quoteProto.id = timestamp
|
||||
quoteProto.author = publicKey
|
||||
text?.let { quoteProto.text = text }
|
||||
addAttachmentsIfNeeded(quoteProto, transaction)
|
||||
// Build
|
||||
try {
|
||||
return quoteProto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct quote proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun addAttachmentsIfNeeded(quoteProto: SignalServiceProtos.DataMessage.Quote.Builder, transaction: String) {
|
||||
val attachmentID = attachmentID ?: return
|
||||
//TODO databas stuff
|
||||
val quotedAttachmentProto = SignalServiceProtos.DataMessage.Quote.QuotedAttachment.newBuilder()
|
||||
//TODO more database related stuff
|
||||
//quotedAttachmentProto.contentType =
|
||||
try {
|
||||
quoteProto.addAttachments(quotedAttachmentProto.build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct quoted attachment proto from: $this")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,102 @@
|
||||
package org.session.messaging.messages.visible
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
import org.session.messaging.messages.Message
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
class VisibleMessage() : VisibleMessageProto<SignalServiceProtos.Content?>() {
|
||||
|
||||
var text: String? = null
|
||||
var attachmentIDs = ArrayList<String>()
|
||||
var quote: Quote? = null
|
||||
var linkPreview: LinkPreview? = null
|
||||
var contact: Contact? = null
|
||||
var profile: Profile? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "BaseVisibleMessage"
|
||||
|
||||
fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? {
|
||||
val dataMessage = proto.dataMessage ?: return null
|
||||
val result = VisibleMessage()
|
||||
result.text = dataMessage.body
|
||||
// Attachments are handled in MessageReceiver
|
||||
val quoteProto = dataMessage.quote
|
||||
quoteProto?.let {
|
||||
val quote = Quote.fromProto(quoteProto)
|
||||
quote?.let { result.quote = quote }
|
||||
}
|
||||
val linkPreviewProto = dataMessage.previewList.first()
|
||||
linkPreviewProto?.let {
|
||||
val linkPreview = LinkPreview.fromProto(linkPreviewProto)
|
||||
linkPreview?.let { result.linkPreview = linkPreview }
|
||||
}
|
||||
// TODO Contact
|
||||
val profile = Profile.fromProto(dataMessage)
|
||||
profile?.let { result.profile = profile }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// validation
|
||||
override fun isValid(): Boolean {
|
||||
if (!super.isValid()) return false
|
||||
if (attachmentIDs.isNotEmpty()) return true
|
||||
val text = text?.trim() ?: return false
|
||||
if (text.isNotEmpty()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun toProto(transaction: String): SignalServiceProtos.Content? {
|
||||
val proto = SignalServiceProtos.Content.newBuilder()
|
||||
var attachmentIDs = this.attachmentIDs
|
||||
val dataMessage: SignalServiceProtos.DataMessage.Builder
|
||||
// Profile
|
||||
val profile = profile
|
||||
val profileProto = profile?.toSSProto()
|
||||
if (profileProto != null) {
|
||||
dataMessage = profileProto.toBuilder()
|
||||
} else {
|
||||
dataMessage = SignalServiceProtos.DataMessage.newBuilder()
|
||||
}
|
||||
// Text
|
||||
text?.let { dataMessage.body = text }
|
||||
// Quote
|
||||
val quotedAttachmentID = quote?.attachmentID
|
||||
quotedAttachmentID?.let {
|
||||
val index = attachmentIDs.indexOf(quotedAttachmentID)
|
||||
if (index >= 0) { attachmentIDs.removeAt(index) }
|
||||
}
|
||||
val quote = quote
|
||||
quote?.let {
|
||||
val quoteProto = quote.toProto(transaction)
|
||||
if (quoteProto != null) dataMessage.quote = quoteProto
|
||||
}
|
||||
//Link preview
|
||||
val linkPreviewAttachmentID = linkPreview?.attachmentID
|
||||
linkPreviewAttachmentID?.let {
|
||||
val index = attachmentIDs.indexOf(quotedAttachmentID)
|
||||
if (index >= 0) { attachmentIDs.removeAt(index) }
|
||||
}
|
||||
val linkPreview = linkPreview
|
||||
linkPreview?.let {
|
||||
val linkPreviewProto = linkPreview.toProto(transaction)
|
||||
linkPreviewProto?.let {
|
||||
dataMessage.addAllPreview(listOf(linkPreviewProto))
|
||||
}
|
||||
}
|
||||
//Attachments
|
||||
// TODO I'm blocking on that one...
|
||||
//swift: let attachments = attachmentIDs.compactMap { TSAttachmentStream.fetch(uniqueId: $0, transaction: transaction) }
|
||||
|
||||
// TODO Contact
|
||||
// Build
|
||||
try {
|
||||
proto.dataMessage = dataMessage.build()
|
||||
return proto.build()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Couldn't construct visible message proto from: $this")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class VisibleMessage : Message() {
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package org.session.libsession.messaging.messages.visible
|
||||
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
|
||||
abstract class VisibleMessageProto<out T: com.google.protobuf.MessageOrBuilder?> : Message() {
|
||||
|
||||
abstract fun toProto(transaction: String): T
|
||||
|
||||
final override fun toProto(): SignalServiceProtos.Content? {
|
||||
//we don't need to implement this method in subclasses
|
||||
//TODO it just needs an equivalent to swift: preconditionFailure("Use toProto(using:) if that exists...
|
||||
TODO("Not implemented")
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
package org.session.messaging.messages.visible.attachments
|
||||
|
||||
internal class Attachment {
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package org.session.libsession.messaging.opengroups
|
||||
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
|
||||
public data class OpenGroup(
|
||||
public val channel: Long,
|
||||
private val serverURL: String,
|
||||
public val displayName: String,
|
||||
public val isDeletable: Boolean
|
||||
) {
|
||||
public val server get() = serverURL.toLowerCase()
|
||||
public 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun toJSON(): Map<String, Any> {
|
||||
return mapOf( "channel" to channel, "server" to server, "displayName" to displayName, "isDeletable" to isDeletable )
|
||||
}
|
||||
}
|
@ -0,0 +1,381 @@
|
||||
package org.session.libsession.messaging.opengroups
|
||||
|
||||
import nl.komponents.kovenant.Kovenant
|
||||
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.Configuration
|
||||
|
||||
import org.session.libsession.messaging.utilities.DotNetAPI
|
||||
import org.session.libsession.messaging.fileserver.FileServerAPI
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.internal.util.Hex
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.utilities.DownloadUtilities
|
||||
import org.session.libsignal.service.loki.utilities.createContext
|
||||
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
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)
|
||||
val sharedContext = Kovenant.createContext("LokiPublicChatAPISharedContext")
|
||||
|
||||
// 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
|
||||
public val openGroupMessageType = "network.loki.messenger.publicChat"
|
||||
@JvmStatic
|
||||
public val profilePictureType = "network.loki.messenger.avatar"
|
||||
|
||||
fun getDefaultChats(): List<OpenGroup> {
|
||||
return listOf() // Don't auto-join any open groups right now
|
||||
}
|
||||
|
||||
public 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
|
||||
public 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 = Configuration.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(sharedContext) { 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, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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 = Configuration.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(sharedContext) { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise<OpenGroupMessage, Exception> {
|
||||
val deferred = deferred<OpenGroupMessage, Exception>()
|
||||
val storage = Configuration.shared.storage
|
||||
val userKeyPair = storage.getUserKeyPair() ?: throw Error.Generic
|
||||
val userDisplayName = storage.getUserDisplayName() ?: throw Error.Generic
|
||||
Thread {
|
||||
val signedMessage = message.sign(userKeyPair.privateKey.serialize())
|
||||
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(sharedContext) { 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
|
||||
@Suppress("NAME_SHADOWING") val message = OpenGroupMessage(serverID, userKeyPair.hexEncodedPublicKey, userDisplayName, text, timestamp, openGroupMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp)
|
||||
message
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
public 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun getModerators(channel: Long, server: String): Promise<Set<String>, Exception> {
|
||||
return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then(sharedContext) { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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(sharedContext) { 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)
|
||||
Configuration.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) {
|
||||
val storage = Configuration.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)
|
||||
}
|
||||
}
|
||||
|
||||
public 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()
|
||||
}
|
||||
}
|
||||
|
||||
public 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun getDisplayNames(publicKeys: Set<String>, server: String): Promise<Map<String, String>, Exception> {
|
||||
return getUserProfiles(publicKeys, server, false).map(sharedContext) { 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
|
||||
}
|
||||
}
|
||||
|
||||
public 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 }
|
||||
}
|
||||
|
||||
public fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise<Unit, Exception> {
|
||||
return setProfilePicture(server, Base64.encodeBytes(profileKey), url)
|
||||
}
|
||||
|
||||
public 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
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package org.session.libsession.messaging.opengroups
|
||||
|
||||
public data class OpenGroupInfo (
|
||||
public val displayName: String,
|
||||
public val profilePictureURL: String,
|
||||
public val memberCount: Int
|
||||
)
|
@ -0,0 +1,242 @@
|
||||
package org.session.libsession.messaging.opengroups
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.util.Hex
|
||||
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
|
||||
import org.whispersystems.curve25519.Curve25519
|
||||
|
||||
public data class OpenGroupMessage(
|
||||
public val serverID: Long?,
|
||||
public val senderPublicKey: String,
|
||||
public val displayName: String,
|
||||
public val body: String,
|
||||
public val timestamp: Long,
|
||||
public val type: String,
|
||||
public val quote: Quote?,
|
||||
public val attachments: List<Attachment>,
|
||||
public val profilePicture: ProfilePicture?,
|
||||
public val signature: Signature?,
|
||||
public val serverTimestamp: Long,
|
||||
) {
|
||||
|
||||
// region Settings
|
||||
companion object {
|
||||
fun from(message: VisibleMessage, server: String): OpenGroupMessage? {
|
||||
val storage = Configuration.shared.storage
|
||||
val userPublicKey = storage.getUserPublicKey() ?: return null
|
||||
// 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 quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey)
|
||||
Quote(quote.timestamp!!, quote.publicKey!!, quote.text!!, quotedMessageServerID)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}()
|
||||
// Message
|
||||
val displayname = storage.getUserDisplayName() ?: "Anonymous"
|
||||
val body = message.text ?: message.sentTimestamp.toString() // 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 attachment = linkPreview.getImage() ?: return@let
|
||||
val openGroupLinkPreview = Attachment(
|
||||
Attachment.Kind.LinkPreview,
|
||||
server,
|
||||
attachment.getId(),
|
||||
attachment.getContentType(),
|
||||
attachment.getSize(),
|
||||
attachment.getFileName(),
|
||||
attachment.getFlags(),
|
||||
attachment.getWidth(),
|
||||
attachment.getHeight(),
|
||||
attachment.getCaption(),
|
||||
attachment.getUrl(),
|
||||
linkPreview.url,
|
||||
linkPreview.title)
|
||||
result.attachments.add(openGroupLinkPreview)
|
||||
}
|
||||
// Attachments
|
||||
val attachments = message.getAttachemnts().forEach {
|
||||
val attachement = Attachment(
|
||||
Attachment.Kind.Attachment,
|
||||
server,
|
||||
it.getId(),
|
||||
it.getContentType(),
|
||||
it.getSize(),
|
||||
it.getFileName(),
|
||||
it.getFlags(),
|
||||
it.getWidth(),
|
||||
it.getHeight(),
|
||||
it.getCaption(),
|
||||
it.getUrl(),
|
||||
linkPreview.getUrl(),
|
||||
linkPreview.getTitle())
|
||||
result.attachments.add(attachement)
|
||||
}
|
||||
// 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
|
||||
public data class ProfilePicture(
|
||||
public val profileKey: ByteArray,
|
||||
public val url: String,
|
||||
)
|
||||
|
||||
public data class Quote(
|
||||
public val quotedMessageTimestamp: Long,
|
||||
public val quoteePublicKey: String,
|
||||
public val quotedMessageBody: String,
|
||||
public val quotedMessageServerID: Long? = null,
|
||||
)
|
||||
|
||||
public data class Signature(
|
||||
public val data: ByteArray,
|
||||
public val version: Long,
|
||||
)
|
||||
|
||||
public data class Attachment(
|
||||
public val kind: Kind,
|
||||
public val server: String,
|
||||
public val serverID: Long,
|
||||
public val contentType: String,
|
||||
public val size: Int,
|
||||
public val fileName: String,
|
||||
public val flags: Int,
|
||||
public val width: Int,
|
||||
public val height: Int,
|
||||
public val caption: String?,
|
||||
public val url: String,
|
||||
/**
|
||||
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
|
||||
*/
|
||||
public val linkPreviewURL: String?,
|
||||
/**
|
||||
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
|
||||
*/
|
||||
public val linkPreviewTitle: String?,
|
||||
) {
|
||||
public val dotNetAPIType = when {
|
||||
contentType.startsWith("image") -> "photo"
|
||||
contentType.startsWith("video") -> "video"
|
||||
contentType.startsWith("audio") -> "audio"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
public 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, 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
|
||||
}
|
@ -1,4 +1,29 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
object MessageReceiver {
|
||||
internal sealed class Error(val description: String) : Exception() {
|
||||
object InvalidMessage: Error("Invalid message.")
|
||||
object UnknownMessage: Error("Unknown message type.")
|
||||
object UnknownEnvelopeType: Error("Unknown envelope type.")
|
||||
object NoUserPublicKey: Error("Couldn't find user key pair.")
|
||||
object NoData: Error("Received an empty envelope.")
|
||||
object SenderBlocked: Error("Received a message from a blocked user.")
|
||||
object NoThread: Error("Couldn't find thread for message.")
|
||||
object SelfSend: Error("Message addressed at self.")
|
||||
object ParsingFailed : Error("Couldn't parse ciphertext message.")
|
||||
// Shared sender keys
|
||||
object InvalidGroupPublicKey: Error("Invalid group public key.")
|
||||
object NoGroupPrivateKey: Error("Missing group private key.")
|
||||
object SharedSecretGenerationFailed: Error("Couldn't generate a shared secret.")
|
||||
|
||||
internal val isRetryable: Boolean = when (this) {
|
||||
is InvalidMessage -> false
|
||||
is UnknownMessage -> false
|
||||
is UnknownEnvelopeType -> false
|
||||
is NoData -> false
|
||||
is SenderBlocked -> false
|
||||
is SelfSend -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,58 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsignal.service.api.push.SignalServiceAddress
|
||||
import org.session.libsignal.service.loki.crypto.LokiServiceCipher
|
||||
import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error
|
||||
import org.session.libsession.utilities.AESGCM
|
||||
import org.whispersystems.curve25519.Curve25519
|
||||
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
|
||||
import org.session.libsignal.libsignal.util.Pair
|
||||
import org.session.libsignal.service.api.messages.SignalServiceEnvelope
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
||||
import org.session.libsignal.service.loki.utilities.toHexString
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object MessageReceiverDecryption {
|
||||
|
||||
internal fun decryptWithSignalProtocol(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
|
||||
val storage = Configuration.shared.signalStorage
|
||||
val certificateValidator = Configuration.shared.certificateValidator
|
||||
val sskDatabase = Configuration.shared.sskDatabase
|
||||
val sessionResetImp = Configuration.shared.sessionResetImp
|
||||
val data = envelope.content
|
||||
if (data.count() == 0) { throw Error.NoData }
|
||||
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
|
||||
val cipher = LokiServiceCipher(SignalServiceAddress(userPublicKey), storage, sskDatabase, sessionResetImp, certificateValidator)
|
||||
val result = cipher.decrypt(envelope)
|
||||
}
|
||||
|
||||
internal fun decryptWithSharedSenderKeys(envelope: SignalServiceEnvelope): Pair<ByteArray, String> {
|
||||
// 1. ) Check preconditions
|
||||
val groupPublicKey = envelope.source
|
||||
if (!Configuration.shared.storage.isClosedGroup(groupPublicKey)) { throw Error.InvalidGroupPublicKey }
|
||||
val data = envelope.content
|
||||
if (data.count() == 0) { throw Error.NoData }
|
||||
val groupPrivateKey = Configuration.shared.storage.getClosedGroupPrivateKey(groupPublicKey) ?: throw Error.NoGroupPrivateKey
|
||||
// 2. ) Parse the wrapper
|
||||
val wrapper = SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.parseFrom(data)
|
||||
val ivAndCiphertext = wrapper.ciphertext.toByteArray()
|
||||
val ephemeralPublicKey = wrapper.ephemeralPublicKey.toByteArray()
|
||||
// 3. ) Decrypt the data inside
|
||||
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(ephemeralPublicKey, groupPrivateKey.serialize())
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
|
||||
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
|
||||
val closedGroupCiphertextMessageAsData = AESGCM.decrypt(ivAndCiphertext, symmetricKey)
|
||||
// 4. ) Parse the closed group ciphertext message
|
||||
val closedGroupCiphertextMessage = ClosedGroupCiphertextMessage.from(closedGroupCiphertextMessageAsData) ?: throw Error.ParsingFailed
|
||||
val senderPublicKey = closedGroupCiphertextMessage.senderPublicKey.toHexString()
|
||||
if (senderPublicKey == Configuration.shared.storage.getUserPublicKey()) { throw Error.SelfSend }
|
||||
// 5. ) Use the info inside the closed group ciphertext message to decrypt the actual message content
|
||||
val plaintext = SharedSenderKeysImplementation.shared.decrypt(closedGroupCiphertextMessage.ivAndCiphertext, groupPublicKey, senderPublicKey, closedGroupCiphertextMessage.keyIndex)
|
||||
// 6. ) Return
|
||||
return Pair(plaintext, senderPublicKey)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
interface MessageReceiverDelegate {
|
||||
}
|
@ -1,4 +1,198 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.jobs.NotifyPNServerJob
|
||||
import org.session.libsession.messaging.opengroups.OpenGroupAPI
|
||||
import org.session.libsession.messaging.opengroups.OpenGroupMessage
|
||||
import org.session.libsession.messaging.utilities.MessageWrapper
|
||||
import org.session.libsession.snode.RawResponsePromise
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.snode.SnodeMessage
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.loki.api.crypto.ProofOfWork
|
||||
|
||||
|
||||
object MessageSender {
|
||||
|
||||
// Error
|
||||
internal sealed class Error(val description: String) : Exception() {
|
||||
object InvalidMessage : Error("Invalid message.")
|
||||
object ProtoConversionFailed : Error("Couldn't convert message to proto.")
|
||||
object ProofOfWorkCalculationFailed : Error("Proof of work calculation failed.")
|
||||
object NoUserPublicKey : Error("Couldn't find user key pair.")
|
||||
|
||||
// Closed groups
|
||||
object NoThread : Error("Couldn't find a thread associated with the given group public key.")
|
||||
object NoPrivateKey : Error("Couldn't find a private key associated with the given group public key.")
|
||||
object InvalidClosedGroupUpdate : Error("Invalid group update.")
|
||||
|
||||
internal val isRetryable: Boolean = when (this) {
|
||||
is InvalidMessage -> false
|
||||
is ProtoConversionFailed -> false
|
||||
is ProofOfWorkCalculationFailed -> false
|
||||
is InvalidClosedGroupUpdate -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience
|
||||
fun send(message: Message, destination: Destination): Promise<Unit, Exception> {
|
||||
if (destination is Destination.OpenGroup) {
|
||||
return sendToOpenGroupDestination(destination, message)
|
||||
}
|
||||
return sendToSnodeDestination(destination, message)
|
||||
}
|
||||
|
||||
// One-on-One Chats & Closed Groups
|
||||
fun sendToSnodeDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
val promise = deferred.promise
|
||||
val storage = Configuration.shared.storage
|
||||
val preconditionFailure = Exception("Destination should not be open groups!")
|
||||
var snodeMessage: SnodeMessage? = null
|
||||
message.sentTimestamp ?: run { message.sentTimestamp = System.currentTimeMillis() } /* Visible messages will already have their sent timestamp set */
|
||||
message.sender = storage.getUserPublicKey()
|
||||
try {
|
||||
when (destination) {
|
||||
is Destination.Contact -> message.recipient = destination.publicKey
|
||||
is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey
|
||||
is Destination.OpenGroup -> throw preconditionFailure
|
||||
}
|
||||
// Validate the message
|
||||
if (!message.isValid()) { throw Error.InvalidMessage }
|
||||
// Convert it to protobuf
|
||||
val proto = message.toProto() ?: throw Error.ProtoConversionFailed
|
||||
// Serialize the protobuf
|
||||
val plaintext = proto.toByteArray()
|
||||
// Encrypt the serialized protobuf
|
||||
val ciphertext: ByteArray
|
||||
when (destination) {
|
||||
is Destination.Contact -> ciphertext = MessageSenderEncryption.encryptWithSignalProtocol(plaintext, message, destination.publicKey)
|
||||
is Destination.ClosedGroup -> ciphertext = MessageSenderEncryption.encryptWithSharedSenderKeys(plaintext, destination.groupPublicKey)
|
||||
is Destination.OpenGroup -> throw preconditionFailure
|
||||
}
|
||||
// Wrap the result
|
||||
val kind: SignalServiceProtos.Envelope.Type
|
||||
val senderPublicKey: String
|
||||
when (destination) {
|
||||
is Destination.Contact -> {
|
||||
kind = SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER
|
||||
senderPublicKey = ""
|
||||
}
|
||||
is Destination.ClosedGroup -> {
|
||||
kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT
|
||||
senderPublicKey = destination.groupPublicKey
|
||||
}
|
||||
is Destination.OpenGroup -> throw preconditionFailure
|
||||
}
|
||||
val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext)
|
||||
// Calculate proof of work
|
||||
val recipient = message.recipient!!
|
||||
val base64EncodedData = Base64.encodeBytes(wrappedMessage)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val nonce = ProofOfWork.calculate(base64EncodedData, recipient, timestamp, message.ttl.toInt()) ?: throw Error.ProofOfWorkCalculationFailed
|
||||
// Send the result
|
||||
snodeMessage = SnodeMessage(recipient, base64EncodedData, message.ttl, timestamp, nonce)
|
||||
SnodeAPI.sendMessage(snodeMessage).success { promises: Set<RawResponsePromise> ->
|
||||
var isSuccess = false
|
||||
val promiseCount = promises.size
|
||||
var errorCount = 0
|
||||
promises.forEach { promise: RawResponsePromise ->
|
||||
promise.success {
|
||||
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
|
||||
isSuccess = true
|
||||
deferred.resolve(Unit)
|
||||
}
|
||||
promise.fail {
|
||||
errorCount += 1
|
||||
if (errorCount != promiseCount) { return@fail } // Only error out if all promises failed
|
||||
deferred.reject(it)
|
||||
}
|
||||
}
|
||||
}.fail {
|
||||
Log.d("Loki", "Couldn't send message due to error: $it.")
|
||||
deferred.reject(it)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
// Handle completion
|
||||
promise.success {
|
||||
handleSuccessfulMessageSend(message)
|
||||
if (message is VisibleMessage && snodeMessage != null) {
|
||||
val notifyPNServerJob = NotifyPNServerJob(snodeMessage)
|
||||
JobQueue.shared.add(notifyPNServerJob)
|
||||
}
|
||||
}
|
||||
promise.fail {
|
||||
handleFailedMessageSend(message, it)
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
// Open Groups
|
||||
fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
val promise = deferred.promise
|
||||
val storage = Configuration.shared.storage
|
||||
val preconditionFailure = Exception("Destination should not be contacts or closed groups!")
|
||||
message.sentTimestamp = System.currentTimeMillis()
|
||||
message.sender = storage.getUserPublicKey()
|
||||
try {
|
||||
val server: String
|
||||
val channel: Long
|
||||
when (destination) {
|
||||
is Destination.Contact -> throw preconditionFailure
|
||||
is Destination.ClosedGroup -> throw preconditionFailure
|
||||
is Destination.OpenGroup -> {
|
||||
message.recipient = "${destination.server}.${destination.channel}"
|
||||
server = destination.server
|
||||
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) ?: throw Error.InvalidMessage
|
||||
// Send the result
|
||||
OpenGroupAPI.sendMessage(openGroupMessage, channel, server).success {
|
||||
message.openGroupServerMessageID = it.serverID
|
||||
deferred.resolve(Unit)
|
||||
}.fail {
|
||||
deferred.reject(it)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
// Handle completion
|
||||
promise.success {
|
||||
handleSuccessfulMessageSend(message)
|
||||
}
|
||||
promise.fail {
|
||||
handleFailedMessageSend(message, it)
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
// Result Handling
|
||||
fun handleSuccessfulMessageSend(message: Message) {
|
||||
|
||||
}
|
||||
|
||||
fun handleFailedMessageSend(message: Message, error: Exception) {
|
||||
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
interface MessageSenderDelegate {
|
||||
}
|
@ -1,4 +1,52 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
package org.session.libsession.messaging.sending_receiving
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
|
||||
import org.session.libsession.messaging.utilities.UnidentifiedAccessUtil
|
||||
import org.session.libsession.utilities.AESGCM
|
||||
|
||||
import org.session.libsignal.libsignal.SignalProtocolAddress
|
||||
import org.session.libsignal.libsignal.loki.ClosedGroupCiphertextMessage
|
||||
import org.session.libsignal.libsignal.util.Hex
|
||||
import org.session.libsignal.libsignal.util.guava.Optional
|
||||
import org.session.libsignal.service.api.crypto.SignalServiceCipher
|
||||
import org.session.libsignal.service.api.push.SignalServiceAddress
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
||||
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
|
||||
|
||||
object MessageSenderEncryption {
|
||||
|
||||
internal fun encryptWithSignalProtocol(plaintext: ByteArray, message: Message, recipientPublicKey: String): ByteArray{
|
||||
val storage = Configuration.shared.signalStorage
|
||||
val sskDatabase = Configuration.shared.sskDatabase
|
||||
val sessionResetImp = Configuration.shared.sessionResetImp
|
||||
val localAddress = SignalServiceAddress(recipientPublicKey)
|
||||
val certificateValidator = Configuration.shared.certificateValidator
|
||||
val cipher = SignalServiceCipher(localAddress, storage, sskDatabase, sessionResetImp, certificateValidator)
|
||||
val signalProtocolAddress = SignalProtocolAddress(recipientPublicKey, 1)
|
||||
val unidentifiedAccessPair = UnidentifiedAccessUtil.getAccessFor(recipientPublicKey)
|
||||
val unidentifiedAccess = if (unidentifiedAccessPair != null) unidentifiedAccessPair.targetUnidentifiedAccess else Optional.absent()
|
||||
val encryptedMessage = cipher.encrypt(signalProtocolAddress, unidentifiedAccess, plaintext)
|
||||
return Base64.decode(encryptedMessage.content)
|
||||
}
|
||||
|
||||
internal fun encryptWithSharedSenderKeys(plaintext: ByteArray, groupPublicKey: String): ByteArray {
|
||||
// 1. ) Encrypt the data with the user's sender key
|
||||
val userPublicKey = Configuration.shared.storage.getUserPublicKey() ?: throw Error.NoUserPublicKey
|
||||
val ciphertextAndKeyIndex = SharedSenderKeysImplementation.shared.encrypt(plaintext, groupPublicKey, userPublicKey)
|
||||
val ivAndCiphertext = ciphertextAndKeyIndex.first
|
||||
val keyIndex = ciphertextAndKeyIndex.second
|
||||
val encryptedMessage = ClosedGroupCiphertextMessage(ivAndCiphertext, Hex.fromStringCondensed(userPublicKey), keyIndex);
|
||||
// 2. ) Encrypt the result for the group's public key to hide the sender public key and key index
|
||||
val intermediate = AESGCM.encrypt(encryptedMessage.serialize(), groupPublicKey.removing05PrefixIfNeeded())
|
||||
// 3. ) Wrap the result
|
||||
return SignalServiceProtos.ClosedGroupCiphertextMessageWrapper.newBuilder()
|
||||
.setCiphertext(ByteString.copyFrom(intermediate.ciphertext))
|
||||
.setEphemeralPublicKey(ByteString.copyFrom(intermediate.ephemeralPublicKey))
|
||||
.build().toByteArray()
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
package org.session.messaging.sending_receiving
|
||||
|
@ -0,0 +1,2 @@
|
||||
package org.session.libsession.messaging.sending_receiving.notifications
|
||||
|
@ -0,0 +1,101 @@
|
||||
package org.session.libsession.messaging.sending_receiving.notifications
|
||||
|
||||
import android.content.Context
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.*
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
import java.io.IOException
|
||||
|
||||
object PushNotificationAPI {
|
||||
val server = "https://live.apns.getsession.org"
|
||||
val serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
|
||||
private val maxRetryCount = 4
|
||||
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
|
||||
|
||||
enum class ClosedGroupOperation {
|
||||
Subscribe, Unsubscribe;
|
||||
|
||||
val rawValue: String
|
||||
get() {
|
||||
return when (this) {
|
||||
Subscribe -> "subscribe_closed_group"
|
||||
Unsubscribe -> "unsubscribe_closed_group"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister(token: String, context: Context) {
|
||||
val parameters = mapOf( "token" to token )
|
||||
val url = "$server/unregister"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code != null && code != 0) {
|
||||
TextSecurePreferences.setIsUsingFCM(context, false)
|
||||
} else {
|
||||
Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
// Unsubscribe from all closed groups
|
||||
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||
allClosedGroupPublicKeys.forEach { closedGroup ->
|
||||
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(token: String, publicKey: String, context: Context, force: Boolean) {
|
||||
val oldToken = TextSecurePreferences.getFCMToken(context)
|
||||
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
|
||||
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
|
||||
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
|
||||
val url = "$server/register"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code != null && code != 0) {
|
||||
TextSecurePreferences.setIsUsingFCM(context, true)
|
||||
TextSecurePreferences.setFCMToken(context, token)
|
||||
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
|
||||
} else {
|
||||
Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
// Subscribe to all closed groups
|
||||
val allClosedGroupPublicKeys = DatabaseFactory.getSSKDatabase(context).getAllClosedGroupPublicKeys()
|
||||
allClosedGroupPublicKeys.forEach { closedGroup ->
|
||||
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
|
||||
if (!TextSecurePreferences.isUsingFCM(context)) { return }
|
||||
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
|
||||
val url = "$server/${operation.rawValue}"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, "/loki/v2/lsrpc").map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code == null || code == 0) {
|
||||
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,268 @@
|
||||
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.MediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
import org.session.libsession.snode.OnionRequestAPI
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.messaging.fileserver.FileServerAPI
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.libsignal.loki.DiffieHellman
|
||||
import org.session.libsignal.service.api.crypto.ProfileCipherOutputStream
|
||||
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.session.libsignal.service.api.push.exceptions.PushNetworkException
|
||||
import org.session.libsignal.service.api.util.StreamDetails
|
||||
import org.session.libsignal.service.internal.push.ProfileAvatarData
|
||||
import org.session.libsignal.service.internal.push.PushAttachmentData
|
||||
import org.session.libsignal.service.internal.push.http.DigestingRequestBody
|
||||
import org.session.libsignal.service.internal.push.http.ProfileCipherOutputStreamFactory
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.internal.util.Hex
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val authTokenRequestCache = hashMapOf<String, Promise<String, Exception>>()
|
||||
}
|
||||
|
||||
public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?)
|
||||
|
||||
public fun getAuthToken(server: String): Promise<String, Exception> {
|
||||
val storage = Configuration.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 = Configuration.shared.storage.getUserKeyPair() ?: throw Error.Generic
|
||||
val parameters: Map<String, Any> = mapOf( "pubKey" to userKeyPair.hexEncodedPublicKey )
|
||||
return execute(HTTPVerb.GET, server, "loki/v1/get_challenge", false, parameters).map(SnodeAPI.sharedContext) { 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.privateKey.serialize())
|
||||
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 = Configuration.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) {
|
||||
Configuration.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)
|
||||
}
|
||||
|
||||
@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.d("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.d("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) {
|
||||
Configuration.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 }
|
@ -0,0 +1,85 @@
|
||||
package org.session.libsession.messaging.utilities
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketMessage
|
||||
import org.session.libsignal.service.internal.websocket.WebSocketProtos.WebSocketRequestMessage
|
||||
import java.security.SecureRandom
|
||||
|
||||
object MessageWrapper {
|
||||
|
||||
// region Types
|
||||
sealed class Error(val description: String) : Exception() {
|
||||
object FailedToWrapData : Error("Failed to wrap data.")
|
||||
object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.")
|
||||
object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.")
|
||||
object FailedToUnwrapData : Error("Failed to unwrap data.")
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Wrapping
|
||||
/**
|
||||
* Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application.
|
||||
*/
|
||||
fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray {
|
||||
try {
|
||||
val envelope = createEnvelope(type, timestamp, senderPublicKey, content)
|
||||
val webSocketMessage = createWebSocketMessage(envelope)
|
||||
return webSocketMessage.toByteArray()
|
||||
} catch (e: Exception) {
|
||||
throw if (e is Error) { e } else { Error.FailedToWrapData }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope {
|
||||
try {
|
||||
val builder = Envelope.newBuilder()
|
||||
builder.type = type
|
||||
builder.timestamp = timestamp
|
||||
builder.source = senderPublicKey
|
||||
builder.sourceDevice = 1
|
||||
builder.content = ByteString.copyFrom(content)
|
||||
return builder.build()
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.")
|
||||
throw Error.FailedToWrapMessageInEnvelope
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage {
|
||||
try {
|
||||
val requestBuilder = WebSocketRequestMessage.newBuilder()
|
||||
requestBuilder.verb = "PUT"
|
||||
requestBuilder.path = "/api/v1/message"
|
||||
requestBuilder.id = SecureRandom.getInstance("SHA1PRNG").nextLong()
|
||||
requestBuilder.body = envelope.toByteString()
|
||||
val messageBuilder = WebSocketMessage.newBuilder()
|
||||
messageBuilder.request = requestBuilder.build()
|
||||
messageBuilder.type = WebSocketMessage.Type.REQUEST
|
||||
return messageBuilder.build()
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to wrap envelope in web socket message: ${e.message}.")
|
||||
throw Error.FailedToWrapEnvelopeInWebSocketMessage
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Unwrapping
|
||||
/**
|
||||
* `data` shouldn't be base 64 encoded.
|
||||
*/
|
||||
fun unwrap(data: ByteArray): Envelope {
|
||||
try {
|
||||
val webSocketMessage = WebSocketMessage.parseFrom(data)
|
||||
val envelopeAsData = webSocketMessage.request.body
|
||||
return Envelope.parseFrom(envelopeAsData)
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to unwrap data: ${e.message}.")
|
||||
throw Error.FailedToUnwrapData
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package org.session.libsession.messaging.utilities
|
||||
|
||||
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
|
||||
import com.goterl.lazycode.lazysodium.SodiumAndroid
|
||||
|
||||
import org.session.libsession.messaging.Configuration
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.metadata.SignalProtos
|
||||
import org.session.libsignal.metadata.certificate.InvalidCertificateException
|
||||
import org.session.libsignal.service.api.crypto.UnidentifiedAccess
|
||||
import org.session.libsignal.service.api.crypto.UnidentifiedAccessPair
|
||||
|
||||
object UnidentifiedAccessUtil {
|
||||
private val TAG = UnidentifiedAccessUtil::class.simpleName
|
||||
private val sodium = LazySodiumAndroid(SodiumAndroid())
|
||||
|
||||
fun getAccessFor(recipientPublicKey: String): UnidentifiedAccessPair? {
|
||||
try {
|
||||
val theirUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipientPublicKey)
|
||||
val ourUnidentifiedAccessKey = getSelfUnidentifiedAccessKey()
|
||||
val ourUnidentifiedAccessCertificate = getUnidentifiedAccessCertificate()
|
||||
|
||||
Log.i(TAG, "Their access key present? " + (theirUnidentifiedAccessKey != null) +
|
||||
" | Our access key present? " + (ourUnidentifiedAccessKey != null) +
|
||||
" | Our certificate present? " + (ourUnidentifiedAccessCertificate != null))
|
||||
|
||||
if (theirUnidentifiedAccessKey != null && ourUnidentifiedAccessKey != null && ourUnidentifiedAccessCertificate != null) {
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(theirUnidentifiedAccessKey, ourUnidentifiedAccessCertificate),
|
||||
UnidentifiedAccess(ourUnidentifiedAccessKey, ourUnidentifiedAccessCertificate))
|
||||
}
|
||||
return null
|
||||
} catch (e: InvalidCertificateException) {
|
||||
Log.w(TAG, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTargetUnidentifiedAccessKey(recipientPublicKey: String): ByteArray? {
|
||||
val theirProfileKey = Configuration.shared.storage.getProfileKeyForRecipient(recipientPublicKey) ?: return sodium.randomBytesBuf(16)
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||
}
|
||||
|
||||
private fun getSelfUnidentifiedAccessKey(): ByteArray? {
|
||||
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
|
||||
if (userPublicKey != null) {
|
||||
return sodium.randomBytesBuf(16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getUnidentifiedAccessCertificate(): ByteArray? {
|
||||
val userPublicKey = Configuration.shared.storage.getUserPublicKey()
|
||||
if (userPublicKey != null) {
|
||||
val certificate = SignalProtos.SenderCertificate.newBuilder().setSender(userPublicKey).setSenderDevice(1).build()
|
||||
return certificate.toByteArray()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
import org.session.libsignal.service.loki.utilities.Broadcaster
|
||||
|
||||
class Configuration(val storage: SnodeStorageProtocol, val broadcaster: Broadcaster) {
|
||||
companion object {
|
||||
lateinit var shared: Configuration
|
||||
|
||||
fun configure(storage: SnodeStorageProtocol, broadcaster: Broadcaster) {
|
||||
if (Companion::shared.isInitialized) { return }
|
||||
shared = Configuration(storage, broadcaster)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,464 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.all
|
||||
import nl.komponents.kovenant.deferred
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.Request
|
||||
import org.session.libsession.utilities.AESGCM
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsignal.service.loki.api.*
|
||||
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
|
||||
import org.session.libsignal.service.loki.api.utilities.*
|
||||
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
||||
import org.session.libsession.utilities.getBodyForOnionRequest
|
||||
import org.session.libsession.utilities.getHeadersForOnionRequest
|
||||
import org.session.libsignal.service.loki.utilities.*
|
||||
|
||||
private typealias Path = List<Snode>
|
||||
|
||||
/**
|
||||
* See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||
*/
|
||||
public object OnionRequestAPI {
|
||||
private val pathFailureCount = mutableMapOf<Path, Int>()
|
||||
private val snodeFailureCount = mutableMapOf<Snode, Int>()
|
||||
public var guardSnodes = setOf<Snode>()
|
||||
public var paths: List<Path> // Not a set to ensure we consistently show the same path to the user
|
||||
get() = SnodeAPI.database.getOnionRequestPaths()
|
||||
set(newValue) {
|
||||
if (newValue.isEmpty()) {
|
||||
SnodeAPI.database.clearOnionRequestPaths()
|
||||
} else {
|
||||
SnodeAPI.database.setOnionRequestPaths(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// region Settings
|
||||
/**
|
||||
* The number of snodes (including the guard snode) in a path.
|
||||
*/
|
||||
private val pathSize = 3
|
||||
/**
|
||||
* The number of times a path can fail before it's replaced.
|
||||
*/
|
||||
private val pathFailureThreshold = 2
|
||||
/**
|
||||
* The number of times a snode can fail before it's replaced.
|
||||
*/
|
||||
private val snodeFailureThreshold = 2
|
||||
/**
|
||||
* The number of paths to maintain.
|
||||
*/
|
||||
public val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path
|
||||
|
||||
/**
|
||||
* The number of guard snodes required to maintain `targetPathCount` paths.
|
||||
*/
|
||||
private val targetGuardSnodeCount
|
||||
get() = targetPathCount // One per path
|
||||
// endregion
|
||||
|
||||
class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>)
|
||||
: Exception("HTTP request failed at destination with status code $statusCode.")
|
||||
class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.")
|
||||
|
||||
private data class OnionBuildingResult(
|
||||
internal val guardSnode: Snode,
|
||||
internal val finalEncryptionResult: EncryptionResult,
|
||||
internal val destinationSymmetricKey: ByteArray
|
||||
)
|
||||
|
||||
internal sealed class Destination {
|
||||
class Snode(val snode: org.session.libsession.snode.Snode) : Destination()
|
||||
class Server(val host: String, val target: String, val x25519PublicKey: String) : Destination()
|
||||
}
|
||||
|
||||
// region Private API
|
||||
/**
|
||||
* Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
|
||||
*/
|
||||
private fun testSnode(snode: Snode): Promise<Unit, Exception> {
|
||||
val deferred = deferred<Unit, Exception>()
|
||||
Thread { // No need to block the shared context for this
|
||||
val url = "${snode.address}:${snode.port}/get_stats/v1"
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.GET, url)
|
||||
val version = json["version"] as? String
|
||||
if (version == null) { deferred.reject(Exception("Missing snode version.")); return@Thread }
|
||||
if (version >= "2.0.7") {
|
||||
deferred.resolve(Unit)
|
||||
} else {
|
||||
val message = "Unsupported snode version: $version."
|
||||
Log.d("Loki", message)
|
||||
deferred.reject(Exception(message))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not
|
||||
* enough (reliable) snodes are available.
|
||||
*/
|
||||
private fun getGuardSnodes(reusableGuardSnodes: List<Snode>): Promise<Set<Snode>, Exception> {
|
||||
if (guardSnodes.count() >= targetGuardSnodeCount) {
|
||||
return Promise.of(guardSnodes)
|
||||
} else {
|
||||
Log.d("Loki", "Populating guard snode cache.")
|
||||
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||
var unusedSnodes = SnodeAPI.snodePool.minus(reusableGuardSnodes)
|
||||
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||
if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() }
|
||||
fun getGuardSnode(): Promise<Snode, Exception> {
|
||||
val candidate = unusedSnodes.getRandomElementOrNull()
|
||||
?: return Promise.ofFail(InsufficientSnodesException())
|
||||
unusedSnodes = unusedSnodes.minus(candidate)
|
||||
Log.d("Loki", "Testing guard snode: $candidate.")
|
||||
// Loop until a reliable guard snode is found
|
||||
val deferred = deferred<Snode, Exception>()
|
||||
testSnode(candidate).success {
|
||||
deferred.resolve(candidate)
|
||||
}.fail {
|
||||
getGuardSnode().success {
|
||||
deferred.resolve(candidate)
|
||||
}.fail { exception ->
|
||||
if (exception is InsufficientSnodesException) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deferred.promise
|
||||
}
|
||||
val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() }
|
||||
all(promises).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||
val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet()
|
||||
OnionRequestAPI.guardSnodes = guardSnodesAsSet
|
||||
guardSnodesAsSet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns `targetPathCount` paths. The returned promise errors out if not
|
||||
* enough (reliable) snodes are available.
|
||||
*/
|
||||
private fun buildPaths(reusablePaths: List<Path>): Promise<List<Path>, Exception> {
|
||||
Log.d("Loki", "Building onion request paths.")
|
||||
SnodeAPI.broadcaster.broadcast("buildingPaths")
|
||||
return SnodeAPI.getRandomSnode().bind(SnodeAPI.sharedContext) { // Just used to populate the snode pool
|
||||
val reusableGuardSnodes = reusablePaths.map { it[0] }
|
||||
getGuardSnodes(reusableGuardSnodes).map(SnodeAPI.sharedContext) { guardSnodes ->
|
||||
var unusedSnodes = SnodeAPI.snodePool.minus(guardSnodes).minus(reusablePaths.flatten())
|
||||
val reusableGuardSnodeCount = reusableGuardSnodes.count()
|
||||
val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||
if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() }
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
guardSnodes.minus(reusableGuardSnodes).map { guardSnode ->
|
||||
val result = listOf( guardSnode ) + (0 until (pathSize - 1)).map {
|
||||
val pathSnode = unusedSnodes.getRandomElement()
|
||||
unusedSnodes = unusedSnodes.minus(pathSnode)
|
||||
pathSnode
|
||||
}
|
||||
Log.d("Loki", "Built new onion request path: $result.")
|
||||
result
|
||||
}
|
||||
}.map { paths ->
|
||||
OnionRequestAPI.paths = paths + reusablePaths
|
||||
SnodeAPI.broadcaster.broadcast("pathsBuilt")
|
||||
paths
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
*/
|
||||
private fun getPath(snodeToExclude: Snode?): Promise<Path, Exception> {
|
||||
if (pathSize < 1) { throw Exception("Can't build path of size zero.") }
|
||||
val paths = this.paths
|
||||
val guardSnodes = mutableSetOf<Snode>()
|
||||
if (paths.isNotEmpty()) {
|
||||
guardSnodes.add(paths[0][0])
|
||||
if (paths.count() >= 2) {
|
||||
guardSnodes.add(paths[1][0])
|
||||
}
|
||||
}
|
||||
OnionRequestAPI.guardSnodes = guardSnodes
|
||||
fun getPath(paths: List<Path>): Path {
|
||||
if (snodeToExclude != null) {
|
||||
return paths.filter { !it.contains(snodeToExclude) }.getRandomElement()
|
||||
} else {
|
||||
return paths.getRandomElement()
|
||||
}
|
||||
}
|
||||
if (paths.count() >= targetPathCount) {
|
||||
return Promise.of(getPath(paths))
|
||||
} else if (paths.isNotEmpty()) {
|
||||
if (paths.any { !it.contains(snodeToExclude) }) {
|
||||
buildPaths(paths) // Re-build paths in the background
|
||||
return Promise.of(getPath(paths))
|
||||
} else {
|
||||
return buildPaths(paths).map(SnodeAPI.sharedContext) { newPaths ->
|
||||
getPath(newPaths)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return buildPaths(listOf()).map(SnodeAPI.sharedContext) { newPaths ->
|
||||
getPath(newPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropGuardSnode(snode: Snode) {
|
||||
guardSnodes = guardSnodes.filter { it != snode }.toSet()
|
||||
}
|
||||
|
||||
private fun dropSnode(snode: Snode) {
|
||||
// We repair the path here because we can do it sync. In the case where we drop a whole
|
||||
// path we leave the re-building up to getPath() because re-building the path in that case
|
||||
// is async.
|
||||
snodeFailureCount[snode] = 0
|
||||
val oldPaths = paths.toMutableList()
|
||||
val pathIndex = oldPaths.indexOfFirst { it.contains(snode) }
|
||||
if (pathIndex == -1) { return }
|
||||
val path = oldPaths[pathIndex].toMutableList()
|
||||
val snodeIndex = path.indexOf(snode)
|
||||
if (snodeIndex == -1) { return }
|
||||
path.removeAt(snodeIndex)
|
||||
val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten())
|
||||
if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() }
|
||||
path.add(unusedSnodes.getRandomElement())
|
||||
// Don't test the new snode as this would reveal the user's IP
|
||||
oldPaths.removeAt(pathIndex)
|
||||
val newPaths = oldPaths + listOf( path )
|
||||
paths = newPaths
|
||||
}
|
||||
|
||||
private fun dropPath(path: Path) {
|
||||
pathFailureCount[path] = 0
|
||||
val paths = OnionRequestAPI.paths.toMutableList()
|
||||
val pathIndex = paths.indexOf(path)
|
||||
if (pathIndex == -1) { return }
|
||||
paths.removeAt(pathIndex)
|
||||
OnionRequestAPI.paths = paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an onion around `payload` and returns the result.
|
||||
*/
|
||||
private fun buildOnionForDestination(payload: Map<*, *>, destination: Destination): Promise<OnionBuildingResult, Exception> {
|
||||
lateinit var guardSnode: Snode
|
||||
lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination
|
||||
lateinit var encryptionResult: EncryptionResult
|
||||
val snodeToExclude = when (destination) {
|
||||
is Destination.Snode -> destination.snode
|
||||
is Destination.Server -> null
|
||||
}
|
||||
return getPath(snodeToExclude).bind(SnodeAPI.sharedContext) { path ->
|
||||
guardSnode = path.first()
|
||||
// Encrypt in reverse order, i.e. the destination first
|
||||
OnionRequestEncryption.encryptPayloadForDestination(payload, destination).bind(SnodeAPI.sharedContext) { r ->
|
||||
destinationSymmetricKey = r.symmetricKey
|
||||
// Recursively encrypt the layers of the onion (again in reverse order)
|
||||
encryptionResult = r
|
||||
@Suppress("NAME_SHADOWING") var path = path
|
||||
var rhs = destination
|
||||
fun addLayer(): Promise<EncryptionResult, Exception> {
|
||||
if (path.isEmpty()) {
|
||||
return Promise.of(encryptionResult)
|
||||
} else {
|
||||
val lhs = Destination.Snode(path.last())
|
||||
path = path.dropLast(1)
|
||||
return OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).bind(SnodeAPI.sharedContext) { r ->
|
||||
encryptionResult = r
|
||||
rhs = lhs
|
||||
addLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
addLayer()
|
||||
}
|
||||
}.map(SnodeAPI.sharedContext) { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an onion request to `destination`. Builds new paths as needed.
|
||||
*/
|
||||
private fun sendOnionRequest(destination: Destination, payload: Map<*, *>, isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||
val deferred = deferred<Map<*, *>, Exception>()
|
||||
lateinit var guardSnode: Snode
|
||||
buildOnionForDestination(payload, destination).success { result ->
|
||||
guardSnode = result.guardSnode
|
||||
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()) {
|
||||
Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.")
|
||||
}
|
||||
@Suppress("NAME_SHADOWING") val parameters = mapOf(
|
||||
"ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
)
|
||||
val body: ByteArray
|
||||
try {
|
||||
body = OnionRequestEncryption.encode(onion, parameters)
|
||||
} catch (exception: Exception) {
|
||||
return@success deferred.reject(exception)
|
||||
}
|
||||
val destinationSymmetricKey = result.destinationSymmetricKey
|
||||
Thread {
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, body)
|
||||
val base64EncodedIVAndCiphertext = json["result"] as? String ?: return@Thread deferred.reject(Exception("Invalid JSON"))
|
||||
val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext)
|
||||
try {
|
||||
val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey)
|
||||
try {
|
||||
@Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java)
|
||||
val statusCode = json["status"] as Int
|
||||
if (statusCode == 406) {
|
||||
@Suppress("NAME_SHADOWING") val body = mapOf( "result" to "Your clock is out of sync with the service node network." )
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||
return@Thread deferred.reject(exception)
|
||||
} else if (json["body"] != null) {
|
||||
@Suppress("NAME_SHADOWING") val body: Map<*, *>
|
||||
if (json["body"] is Map<*, *>) {
|
||||
body = json["body"] as Map<*, *>
|
||||
} else {
|
||||
val bodyAsString = json["body"] as String
|
||||
if (!isJSONRequired) {
|
||||
body = mapOf( "result" to bodyAsString )
|
||||
} else {
|
||||
body = JsonUtil.fromJson(bodyAsString, Map::class.java)
|
||||
}
|
||||
}
|
||||
if (statusCode != 200) {
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, body)
|
||||
return@Thread deferred.reject(exception)
|
||||
}
|
||||
deferred.resolve(body)
|
||||
} else {
|
||||
if (statusCode != 200) {
|
||||
val exception = HTTPRequestFailedAtDestinationException(statusCode, json)
|
||||
return@Thread deferred.reject(exception)
|
||||
}
|
||||
deferred.resolve(json)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}."))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
}.fail { exception ->
|
||||
deferred.reject(exception)
|
||||
}
|
||||
val promise = deferred.promise
|
||||
promise.fail { exception ->
|
||||
val path = paths.firstOrNull { it.contains(guardSnode) }
|
||||
if (exception is HTTP.HTTPRequestFailedException) {
|
||||
fun handleUnspecificError() {
|
||||
if (path == null) { return }
|
||||
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0
|
||||
pathFailureCount += 1
|
||||
if (pathFailureCount >= pathFailureThreshold) {
|
||||
dropGuardSnode(guardSnode)
|
||||
path.forEach { snode ->
|
||||
@Suppress("ThrowableNotThrown")
|
||||
SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw
|
||||
}
|
||||
dropPath(path)
|
||||
} else {
|
||||
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
|
||||
}
|
||||
}
|
||||
val json = exception.json
|
||||
val message = json?.get("result") as? String
|
||||
val prefix = "Next node not found: "
|
||||
if (message != null && message.startsWith(prefix)) {
|
||||
val ed25519PublicKey = message.substringAfter(prefix)
|
||||
val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey }
|
||||
if (snode != null) {
|
||||
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0
|
||||
snodeFailureCount += 1
|
||||
if (snodeFailureCount >= snodeFailureThreshold) {
|
||||
@Suppress("ThrowableNotThrown")
|
||||
SnodeAPI.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw
|
||||
try {
|
||||
dropSnode(snode)
|
||||
} catch (exception: Exception) {
|
||||
handleUnspecificError()
|
||||
}
|
||||
} else {
|
||||
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
|
||||
}
|
||||
} else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
} else if (message == "Loki Server error") {
|
||||
// Do nothing
|
||||
} else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Internal API
|
||||
/**
|
||||
* Sends an onion request to `snode`. Builds new paths as needed.
|
||||
*/
|
||||
internal fun sendOnionRequest(method: Snode.Method, parameters: Map<*, *>, snode: Snode, publicKey: String): Promise<Map<*, *>, Exception> {
|
||||
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||
return sendOnionRequest(Destination.Snode(snode), payload).recover { exception ->
|
||||
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
|
||||
if (httpRequestFailedException != null) {
|
||||
val error = SnodeAPI.handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
|
||||
if (error != null) { throw error }
|
||||
}
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an onion request to `server`. Builds new paths as needed.
|
||||
*
|
||||
* `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance.
|
||||
*/
|
||||
public fun sendOnionRequest(request: Request, server: String, x25519PublicKey: String, target: String = "/loki/v3/lsrpc", isJSONRequired: Boolean = true): Promise<Map<*, *>, Exception> {
|
||||
val headers = request.getHeadersForOnionRequest()
|
||||
val url = request.url()
|
||||
val urlAsString = url.toString()
|
||||
val host = url.host()
|
||||
val endpoint = when {
|
||||
server.count() < urlAsString.count() -> urlAsString.substringAfter("$server/")
|
||||
else -> ""
|
||||
}
|
||||
val body = request.getBodyForOnionRequest() ?: "null"
|
||||
val payload = mapOf(
|
||||
"body" to body,
|
||||
"endpoint" to endpoint,
|
||||
"method" to request.method(),
|
||||
"headers" to headers
|
||||
)
|
||||
val destination = Destination.Server(host, target, x25519PublicKey)
|
||||
return sendOnionRequest(destination, payload, isJSONRequired).recover { exception ->
|
||||
Log.d("Loki", "Couldn't reach server: $urlAsString due to error: $exception.")
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import org.session.libsignal.service.internal.util.JsonUtil
|
||||
import org.session.libsession.utilities.AESGCM.EncryptionResult
|
||||
import org.session.libsession.utilities.AESGCM
|
||||
import org.session.libsignal.service.loki.utilities.toHexString
|
||||
import java.nio.Buffer
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
object OnionRequestEncryption {
|
||||
|
||||
internal fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray {
|
||||
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
|
||||
val jsonAsData = JsonUtil.toJson(json).toByteArray()
|
||||
val ciphertextSize = ciphertext.size
|
||||
val buffer = ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN)
|
||||
buffer.putInt(ciphertextSize)
|
||||
val ciphertextSizeAsData = ByteArray(buffer.capacity())
|
||||
// Casting here avoids an issue where this gets compiled down to incorrect byte code. See
|
||||
// https://github.com/eclipse/jetty.project/issues/3244 for more info
|
||||
(buffer as Buffer).position(0)
|
||||
buffer.get(ciphertextSizeAsData)
|
||||
return ciphertextSizeAsData + ciphertext + jsonAsData
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||
*/
|
||||
internal fun encryptPayloadForDestination(payload: Map<*, *>, destination: OnionRequestAPI.Destination): Promise<EncryptionResult, Exception> {
|
||||
val deferred = deferred<EncryptionResult, Exception>()
|
||||
Thread {
|
||||
try {
|
||||
// Wrapping isn't needed for file server or open group onion requests
|
||||
when (destination) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
val snodeX25519PublicKey = destination.snode.publicKeySet!!.x25519Key
|
||||
val payloadAsData = JsonUtil.toJson(payload).toByteArray()
|
||||
val plaintext = encode(payloadAsData, mapOf( "headers" to "" ))
|
||||
val result = AESGCM.encrypt(plaintext, snodeX25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
val plaintext = JsonUtil.toJson(payload).toByteArray()
|
||||
val result = AESGCM.encrypt(plaintext, destination.x25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
*/
|
||||
internal fun encryptHop(lhs: OnionRequestAPI.Destination, rhs: OnionRequestAPI.Destination, previousEncryptionResult: EncryptionResult): Promise<EncryptionResult, Exception> {
|
||||
val deferred = deferred<EncryptionResult, Exception>()
|
||||
Thread {
|
||||
try {
|
||||
val payload: MutableMap<String, Any>
|
||||
when (rhs) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
payload = mutableMapOf( "destination" to rhs.snode.publicKeySet!!.ed25519Key )
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
payload = mutableMapOf( "host" to rhs.host, "target" to rhs.target, "method" to "POST" )
|
||||
}
|
||||
}
|
||||
payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
val x25519PublicKey: String
|
||||
when (lhs) {
|
||||
is OnionRequestAPI.Destination.Snode -> {
|
||||
x25519PublicKey = lhs.snode.publicKeySet!!.x25519Key
|
||||
}
|
||||
is OnionRequestAPI.Destination.Server -> {
|
||||
x25519PublicKey = lhs.x25519PublicKey
|
||||
}
|
||||
}
|
||||
val plaintext = encode(previousEncryptionResult.ciphertext, payload)
|
||||
val result = AESGCM.encrypt(plaintext, x25519PublicKey)
|
||||
deferred.resolve(result)
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
public class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) {
|
||||
|
||||
val ip: String get() = address.removePrefix("https://")
|
||||
|
||||
internal enum class Method(val rawValue: String) {
|
||||
/**
|
||||
* Only supported by snode targets.
|
||||
*/
|
||||
GetSwarm("get_snodes_for_pubkey"),
|
||||
/**
|
||||
* Only supported by snode targets.
|
||||
*/
|
||||
GetMessages("retrieve"),
|
||||
SendMessage("store")
|
||||
}
|
||||
|
||||
data class KeySet(val ed25519Key: String, val x25519Key: String)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return if (other is Snode) {
|
||||
address == other.address && port == other.port
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return address.hashCode() xor port.hashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String { return "$address:$port" }
|
||||
}
|
@ -0,0 +1,370 @@
|
||||
@file:Suppress("NAME_SHADOWING")
|
||||
|
||||
package org.session.libsession.snode
|
||||
|
||||
import nl.komponents.kovenant.Kovenant
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.deferred
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import nl.komponents.kovenant.task
|
||||
|
||||
import org.session.libsession.snode.utilities.getRandomElement
|
||||
|
||||
import org.session.libsignal.libsignal.logging.Log
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import org.session.libsignal.service.loki.api.MessageWrapper
|
||||
import org.session.libsignal.service.loki.api.utilities.HTTP
|
||||
import org.session.libsignal.service.loki.utilities.createContext
|
||||
import org.session.libsignal.service.loki.utilities.prettifiedDescription
|
||||
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||
import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
object SnodeAPI {
|
||||
val database = Configuration.shared.storage
|
||||
val broadcaster = Configuration.shared.broadcaster
|
||||
val sharedContext = Kovenant.createContext("LokiAPISharedContext")
|
||||
val messageSendingContext = Kovenant.createContext("LokiAPIMessageSendingContext")
|
||||
val messagePollingContext = Kovenant.createContext("LokiAPIMessagePollingContext")
|
||||
|
||||
internal var snodeFailureCount: MutableMap<Snode, Int> = mutableMapOf()
|
||||
internal var snodePool: Set<Snode>
|
||||
get() = database.getSnodePool()
|
||||
set(newValue) { database.setSnodePool(newValue) }
|
||||
|
||||
// Settings
|
||||
private val maxRetryCount = 6
|
||||
private val minimumSnodePoolCount = 64
|
||||
private val minimumSwarmSnodeCount = 2
|
||||
private val seedNodePool: Set<String> = setOf( "https://storage.seed1.loki.network", "https://storage.seed3.loki.network", "https://public.loki.foundation" )
|
||||
internal val snodeFailureThreshold = 4
|
||||
private val targetSwarmSnodeCount = 2
|
||||
|
||||
private val useOnionRequests = true
|
||||
|
||||
internal var powDifficulty = 1
|
||||
|
||||
// Error
|
||||
internal sealed class Error(val description: String) : Exception() {
|
||||
object Generic : Error("An error occurred.")
|
||||
object ClockOutOfSync : Error("The user's clock is out of sync with the service node network.")
|
||||
object RandomSnodePoolUpdatingFailed : Error("Failed to update random service node pool.")
|
||||
}
|
||||
|
||||
// Internal API
|
||||
internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String, parameters: Map<String, String>): RawResponsePromise {
|
||||
val url = "${snode.address}:${snode.port}/storage_rpc/v1"
|
||||
if (useOnionRequests) {
|
||||
return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey)
|
||||
} else {
|
||||
val deferred = deferred<Map<*, *>, Exception>()
|
||||
Thread {
|
||||
val payload = mapOf( "method" to method.rawValue, "params" to parameters )
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, payload)
|
||||
deferred.resolve(json)
|
||||
} catch (exception: Exception) {
|
||||
val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException
|
||||
if (httpRequestFailedException != null) {
|
||||
val error = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey)
|
||||
if (error != null) { return@Thread deferred.reject(exception) }
|
||||
}
|
||||
Log.d("Loki", "Unhandled exception: $exception.")
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getRandomSnode(): Promise<Snode, Exception> {
|
||||
val snodePool = this.snodePool
|
||||
if (snodePool.count() < minimumSnodePoolCount) {
|
||||
val target = seedNodePool.random()
|
||||
val url = "$target/json_rpc"
|
||||
Log.d("Loki", "Populating snode pool using: $target.")
|
||||
val parameters = mapOf(
|
||||
"method" to "get_n_service_nodes",
|
||||
"params" to mapOf(
|
||||
"active_only" to true,
|
||||
"fields" to mapOf( "public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true )
|
||||
)
|
||||
)
|
||||
val deferred = deferred<Snode, Exception>()
|
||||
deferred<org.session.libsignal.service.loki.api.Snode, Exception>(SnodeAPI.sharedContext)
|
||||
Thread {
|
||||
try {
|
||||
val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true)
|
||||
val intermediate = json["result"] as? Map<*, *>
|
||||
val rawSnodes = intermediate?.get("service_node_states") as? List<*>
|
||||
if (rawSnodes != null) {
|
||||
val snodePool = rawSnodes.mapNotNull { rawSnode ->
|
||||
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||
val address = rawSnodeAsJSON?.get("public_ip") as? String
|
||||
val port = rawSnodeAsJSON?.get("storage_port") as? Int
|
||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}.toMutableSet()
|
||||
Log.d("Loki", "Persisting snode pool to database.")
|
||||
this.snodePool = snodePool
|
||||
try {
|
||||
deferred.resolve(snodePool.getRandomElement())
|
||||
} catch (exception: Exception) {
|
||||
Log.d("Loki", "Got an empty snode pool from: $target.")
|
||||
deferred.reject(SnodeAPI.Error.Generic)
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.")
|
||||
deferred.reject(SnodeAPI.Error.Generic)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
deferred.reject(exception)
|
||||
}
|
||||
}.start()
|
||||
return deferred.promise
|
||||
} else {
|
||||
return Promise.of(snodePool.getRandomElement())
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) {
|
||||
val swarm = database.getSwarm(publicKey)?.toMutableSet()
|
||||
if (swarm != null && swarm.contains(snode)) {
|
||||
swarm.remove(snode)
|
||||
database.setSwarm(publicKey, swarm)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getSingleTargetSnode(publicKey: String): Promise<Snode, Exception> {
|
||||
// SecureRandom() should be cryptographically secure
|
||||
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() }
|
||||
}
|
||||
|
||||
// Public API
|
||||
fun getTargetSnodes(publicKey: String): Promise<List<Snode>, Exception> {
|
||||
// SecureRandom() should be cryptographically secure
|
||||
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).take(targetSwarmSnodeCount) }
|
||||
}
|
||||
|
||||
fun getSwarm(publicKey: String): Promise<Set<Snode>, Exception> {
|
||||
val cachedSwarm = database.getSwarm(publicKey)
|
||||
if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
|
||||
val cachedSwarmCopy = mutableSetOf<Snode>() // Workaround for a Kotlin compiler issue
|
||||
cachedSwarmCopy.addAll(cachedSwarm)
|
||||
return task { cachedSwarmCopy }
|
||||
} else {
|
||||
val parameters = mapOf( "pubKey" to publicKey )
|
||||
return getRandomSnode().bind {
|
||||
invoke(Snode.Method.GetSwarm, it, publicKey, parameters)
|
||||
}.map(SnodeAPI.sharedContext) {
|
||||
parseSnodes(it).toSet()
|
||||
}.success {
|
||||
database.setSwarm(publicKey, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRawMessages(snode: Snode, publicKey: String): RawResponsePromise {
|
||||
val lastHashValue = database.getLastMessageHashValue(snode, publicKey) ?: ""
|
||||
val parameters = mapOf( "pubKey" to publicKey, "lastHash" to lastHashValue )
|
||||
return invoke(Snode.Method.GetMessages, snode, publicKey, parameters)
|
||||
}
|
||||
|
||||
fun getMessages(publicKey: String): MessageListPromise {
|
||||
return retryIfNeeded(maxRetryCount) {
|
||||
getSingleTargetSnode(publicKey).bind(messagePollingContext) { snode ->
|
||||
getRawMessages(snode, publicKey).map(messagePollingContext) { parseRawMessagesResponse(it, snode, publicKey) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: SnodeMessage): Promise<Set<RawResponsePromise>, Exception> {
|
||||
val destination = message.recipient
|
||||
fun broadcast(event: String) {
|
||||
val dayInMs: Long = 86400000
|
||||
if (message.ttl != dayInMs && message.ttl != 4 * dayInMs) { return }
|
||||
broadcaster.broadcast(event, message.timestamp)
|
||||
}
|
||||
broadcast("calculatingPoW")
|
||||
return retryIfNeeded(maxRetryCount) {
|
||||
getTargetSnodes(destination).map(messageSendingContext) { swarm ->
|
||||
swarm.map { snode ->
|
||||
broadcast("sendingMessage")
|
||||
val parameters = message.toJSON()
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
invoke(Snode.Method.SendMessage, snode, destination, parameters).map(messageSendingContext) { rawResponse ->
|
||||
val json = rawResponse as? Map<*, *>
|
||||
val powDifficulty = json?.get("difficulty") as? Int
|
||||
if (powDifficulty != null) {
|
||||
if (powDifficulty != SnodeAPI.powDifficulty && powDifficulty < 100) {
|
||||
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||
SnodeAPI.powDifficulty = powDifficulty
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update proof of work difficulty from: ${rawResponse.prettifiedDescription()}.")
|
||||
}
|
||||
rawResponse
|
||||
}
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing
|
||||
private fun parseSnodes(rawResponse: Any): List<Snode> {
|
||||
val json = rawResponse as? Map<*, *>
|
||||
val rawSnodes = json?.get("snodes") as? List<*>
|
||||
if (rawSnodes != null) {
|
||||
return rawSnodes.mapNotNull { rawSnode ->
|
||||
val rawSnodeAsJSON = rawSnode as? Map<*, *>
|
||||
val address = rawSnodeAsJSON?.get("ip") as? String
|
||||
val portAsString = rawSnodeAsJSON?.get("port") as? String
|
||||
val port = portAsString?.toInt()
|
||||
val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String
|
||||
val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String
|
||||
if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") {
|
||||
Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key))
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.")
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String): List<Envelope> {
|
||||
val messages = rawResponse["messages"] as? List<*>
|
||||
return if (messages != null) {
|
||||
updateLastMessageHashValueIfPossible(snode, publicKey, messages)
|
||||
val newRawMessages = removeDuplicates(publicKey, messages)
|
||||
parseEnvelopes(newRawMessages)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>) {
|
||||
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
|
||||
val hashValue = lastMessageAsJSON?.get("hash") as? String
|
||||
val expiration = lastMessageAsJSON?.get("expiration") as? Int
|
||||
if (hashValue != null) {
|
||||
database.setLastMessageHashValue(snode, publicKey, hashValue)
|
||||
} else if (rawMessages.isNotEmpty()) {
|
||||
Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeDuplicates(publicKey: String, rawMessages: List<*>): List<*> {
|
||||
val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey)?.toMutableSet() ?: mutableSetOf()
|
||||
return rawMessages.filter { rawMessage ->
|
||||
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||
val hashValue = rawMessageAsJSON?.get("hash") as? String
|
||||
if (hashValue != null) {
|
||||
val isDuplicate = receivedMessageHashValues.contains(hashValue)
|
||||
receivedMessageHashValues.add(hashValue)
|
||||
database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues)
|
||||
!isDuplicate
|
||||
} else {
|
||||
Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEnvelopes(rawMessages: List<*>): List<Envelope> {
|
||||
return rawMessages.mapNotNull { rawMessage ->
|
||||
val rawMessageAsJSON = rawMessage as? Map<*, *>
|
||||
val base64EncodedData = rawMessageAsJSON?.get("data") as? String
|
||||
val data = base64EncodedData?.let { Base64.decode(it) }
|
||||
if (data != null) {
|
||||
try {
|
||||
MessageWrapper.unwrap(data)
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error Handling
|
||||
internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception? {
|
||||
fun handleBadSnode() {
|
||||
val oldFailureCount = snodeFailureCount[snode] ?: 0
|
||||
val newFailureCount = oldFailureCount + 1
|
||||
snodeFailureCount[snode] = newFailureCount
|
||||
Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.")
|
||||
if (newFailureCount >= snodeFailureThreshold) {
|
||||
Log.d("Loki", "Failure threshold reached for: $snode; dropping it.")
|
||||
if (publicKey != null) {
|
||||
dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||
}
|
||||
snodePool = snodePool.toMutableSet().minus(snode).toSet()
|
||||
Log.d("Loki", "Snode pool count: ${snodePool.count()}.")
|
||||
snodeFailureCount[snode] = 0
|
||||
}
|
||||
}
|
||||
when (statusCode) {
|
||||
400, 500, 503 -> { // Usually indicates that the snode isn't up to date
|
||||
handleBadSnode()
|
||||
}
|
||||
406 -> {
|
||||
Log.d("Loki", "The user's clock is out of sync with the service node network.")
|
||||
broadcaster.broadcast("clockOutOfSync")
|
||||
return Error.ClockOutOfSync
|
||||
}
|
||||
421 -> {
|
||||
// The snode isn't associated with the given public key anymore
|
||||
if (publicKey != null) {
|
||||
Log.d("Loki", "Invalidating swarm for: $publicKey.")
|
||||
dropSnodeFromSwarmIfNeeded(snode, publicKey)
|
||||
} else {
|
||||
Log.d("Loki", "Got a 421 without an associated public key.")
|
||||
}
|
||||
}
|
||||
432 -> {
|
||||
// The PoW difficulty is too low
|
||||
val powDifficulty = json?.get("difficulty") as? Int
|
||||
if (powDifficulty != null) {
|
||||
if (powDifficulty < 100) {
|
||||
Log.d("Loki", "Setting proof of work difficulty to $powDifficulty (snode: $snode).")
|
||||
SnodeAPI.powDifficulty = powDifficulty
|
||||
} else {
|
||||
handleBadSnode()
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to update proof of work difficulty.")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
handleBadSnode()
|
||||
Log.d("Loki", "Unhandled response code: ${statusCode}.")
|
||||
return Error.Generic
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Type Aliases
|
||||
typealias RawResponse = Map<*, *>
|
||||
typealias MessageListPromise = Promise<List<Envelope>, Exception>
|
||||
typealias RawResponsePromise = Promise<RawResponse, Exception>
|
@ -0,0 +1,23 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
data class SnodeMessage(
|
||||
// The hex encoded public key of the recipient.
|
||||
val recipient: String,
|
||||
// The content of the message.
|
||||
val data: String,
|
||||
// The time to live for the message in milliseconds.
|
||||
val ttl: Long,
|
||||
// When the proof of work was calculated.
|
||||
val timestamp: Long,
|
||||
// The base 64 encoded proof of work.
|
||||
val nonce: String
|
||||
) {
|
||||
internal fun toJSON(): Map<String, String> {
|
||||
return mutableMapOf(
|
||||
"pubKey" to recipient,
|
||||
"data" to data,
|
||||
"ttl" to ttl.toString(),
|
||||
"timestamp" to timestamp.toString(),
|
||||
"nonce" to nonce)
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package org.session.libsession.snode
|
||||
|
||||
interface SnodeStorageProtocol {
|
||||
fun getSnodePool(): Set<Snode>
|
||||
fun setSnodePool(newValue: Set<Snode>)
|
||||
fun getOnionRequestPaths(): List<List<Snode>>
|
||||
fun clearOnionRequestPaths()
|
||||
fun setOnionRequestPaths(newValue: List<List<Snode>>)
|
||||
fun getSwarm(publicKey: String): Set<Snode>?
|
||||
fun setSwarm(publicKey: String, newValue: Set<Snode>)
|
||||
fun getLastMessageHashValue(snode: Snode, publicKey: String): String?
|
||||
fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String)
|
||||
fun getReceivedMessageHashValues(publicKey: String): Set<String>?
|
||||
fun setReceivedMessageHashValues(publicKey: String, newValue: Set<String>)
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package org.session.libsession.utilities
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okio.Buffer
|
||||
import org.session.libsignal.service.internal.util.Base64
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
internal fun Request.getHeadersForOnionRequest(): Map<String, Any> {
|
||||
val result = mutableMapOf<String, Any>()
|
||||
val contentType = body()?.contentType()
|
||||
if (contentType != null) {
|
||||
result["content-type"] = contentType.toString()
|
||||
}
|
||||
val headers = headers()
|
||||
for (name in headers.names()) {
|
||||
val value = headers.get(name)
|
||||
if (value != null) {
|
||||
if (value.toLowerCase(Locale.US) == "true" || value.toLowerCase(Locale.US) == "false") {
|
||||
result[name] = value.toBoolean()
|
||||
} else if (value.toIntOrNull() != null) {
|
||||
result[name] = value.toInt()
|
||||
} else {
|
||||
result[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal fun Request.getBodyForOnionRequest(): Any? {
|
||||
try {
|
||||
val copyOfThis = newBuilder().build()
|
||||
val buffer = Buffer()
|
||||
val body = copyOfThis.body() ?: return null
|
||||
body.writeTo(buffer)
|
||||
val bodyAsData = buffer.readByteArray()
|
||||
if (body is MultipartBody) {
|
||||
val base64EncodedBody: String = Base64.encodeBytes(bodyAsData)
|
||||
return mapOf( "fileUpload" to base64EncodedBody )
|
||||
} else {
|
||||
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
|
||||
return bodyAsData?.toString(charset)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package org.session.libsession.snode.utilities
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Uses `SecureRandom` to pick an element from this collection.
|
||||
*/
|
||||
fun <T> Collection<T>.getRandomElementOrNull(): T? {
|
||||
val index = SecureRandom().nextInt(size) // SecureRandom() should be cryptographically secure
|
||||
return elementAtOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `SecureRandom` to pick an element from this collection.
|
||||
*/
|
||||
fun <T> Collection<T>.getRandomElement(): T {
|
||||
return getRandomElementOrNull()!!
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package org.session.libsession.utilities
|
||||
|
||||
import org.whispersystems.curve25519.Curve25519
|
||||
import org.session.libsignal.libsignal.util.ByteUtil
|
||||
import org.session.libsignal.libsignal.util.Hex
|
||||
import org.session.libsignal.service.internal.util.Util
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
internal object AESGCM {
|
||||
|
||||
internal data class EncryptionResult(
|
||||
internal val ciphertext: ByteArray,
|
||||
internal val symmetricKey: ByteArray,
|
||||
internal val ephemeralPublicKey: ByteArray
|
||||
)
|
||||
|
||||
internal val gcmTagSize = 128
|
||||
internal val ivSize = 12
|
||||
|
||||
/**
|
||||
* Sync. Don't call from the main thread.
|
||||
*/
|
||||
internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||
val iv = ivAndCiphertext.sliceArray(0 until ivSize)
|
||||
val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count())
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync. Don't call from the main thread.
|
||||
*/
|
||||
internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray {
|
||||
val iv = Util.getSecretBytes(ivSize)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv))
|
||||
return ByteUtil.combine(iv, cipher.doFinal(plaintext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync. Don't call from the main thread.
|
||||
*/
|
||||
internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult {
|
||||
val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey)
|
||||
val ephemeralKeyPair = Curve25519.getInstance(Curve25519.BEST).generateKeyPair()
|
||||
val ephemeralSharedSecret = Curve25519.getInstance(Curve25519.BEST).calculateAgreement(x25519PublicKey, ephemeralKeyPair.privateKey)
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256"))
|
||||
val symmetricKey = mac.doFinal(ephemeralSharedSecret)
|
||||
val ciphertext = encrypt(plaintext, symmetricKey)
|
||||
return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.publicKey)
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user