diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 90a3834d2f..cb60005f17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.jobs.AttachmentUploadJob import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil +import java.io.InputStream class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), MessageDataProvider { @@ -39,6 +40,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) attachmentUploadJob.onRun() } + override fun insertAttachment(messageId: Long, attachmentId: Long, stream : InputStream) { + val attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context) + attachmentDatabase.insertAttachmentsForPlaceholder(messageId, AttachmentId(attachmentId,0), stream) + } + override fun isOutgoingMessage(timestamp: Long): Boolean { val smsDatabase = DatabaseFactory.getSmsDatabase(context) return smsDatabase.isOutgoingMessage(timestamp) @@ -61,10 +67,11 @@ fun DatabaseAttachment.toAttachmentStream(context: Context): SessionServiceAttac attachmentStream.key = ByteString.copyFrom(this.key?.toByteArray()) attachmentStream.digest = this.digest - //attachmentStream.flags = if (this.isVoiceNote) SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE.number else 0 attachmentStream.url = this.url + //TODO attachmentStream.listener + return attachmentStream } diff --git a/libsession/build.gradle b/libsession/build.gradle index 0ae0a9ca03..97b47e628b 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -65,4 +65,6 @@ dependencies { testImplementation "org.assertj:assertj-core:1.7.1" testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" + implementation 'org.greenrobot:eventbus:3.0.0' + } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index a865da87ad..598ac75534 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -4,6 +4,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentPointer import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentStream import org.session.libsignal.service.api.messages.SignalServiceAttachmentPointer +import java.io.InputStream interface MessageDataProvider { @@ -20,6 +21,8 @@ interface MessageDataProvider { fun setAttachmentState(attachmentState: AttachmentState, attachmentId: Long, messageID: Long) + fun insertAttachment(messageId: Long, attachmentId: Long, stream : InputStream) + fun isOutgoingMessage(timestamp: Long): Boolean @Throws(Exception::class) diff --git a/libsession/src/main/java/org/session/libsession/events/AttachmentProgressEvent.kt b/libsession/src/main/java/org/session/libsession/events/AttachmentProgressEvent.kt new file mode 100644 index 0000000000..9a17652a57 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/events/AttachmentProgressEvent.kt @@ -0,0 +1,6 @@ +package org.session.libsession.events + +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachment + +class AttachmentProgressEvent(attachment: SessionServiceAttachment, total: Long, progress: Long) { +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 4c06c85547..c7d7347085 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,17 +1,86 @@ package org.session.libsession.messaging.jobs -class AttachmentDownloadJob: Job { +import org.greenrobot.eventbus.EventBus +import org.session.libsession.events.AttachmentProgressEvent +import org.session.libsession.messaging.MessagingConfiguration +import org.session.libsession.messaging.fileserver.FileServerAPI +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachment +import org.session.libsession.messaging.utilities.DotNetAPI +import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream +import org.session.libsignal.service.api.messages.SignalServiceAttachment +import java.io.File +import java.io.FileInputStream + +class AttachmentDownloadJob(val attachmentID: Long, val tsIncomingMessageID: Long): Job { + override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 + private val MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 + + // Error + internal sealed class Error(val description: String) : Exception() { + object NoAttachment : Error("No such attachment.") + } + // Settings - override val maxFailureCount: Int = 100 + override val maxFailureCount: Int = 20 companion object { val collection: String = "AttachmentDownloadJobCollection" } override fun execute() { - TODO("Not yet implemented") + val messageDataProvider = MessagingConfiguration.shared.messageDataProvider + val attachmentPointer = messageDataProvider.getAttachmentPointer(attachmentID) ?: return handleFailure(Error.NoAttachment) + messageDataProvider.setAttachmentState(AttachmentState.STARTED, attachmentID, this.tsIncomingMessageID) + val tempFile = createTempFile() + val handleFailure: (java.lang.Exception) -> Unit = { exception -> + tempFile.delete() + if(exception is Error && exception == Error.NoAttachment) { + MessagingConfiguration.shared.messageDataProvider.setAttachmentState(AttachmentState.FAILED, attachmentID, tsIncomingMessageID) + this.handlePermanentFailure(exception) + } else if (exception is DotNetAPI.Error && exception == DotNetAPI.Error.ParsingFailed) { + // No need to retry if the response is invalid. Most likely this means we (incorrectly) + // got a "Cannot GET ..." error from the file server. + MessagingConfiguration.shared.messageDataProvider.setAttachmentState(AttachmentState.FAILED, attachmentID, tsIncomingMessageID) + this.handlePermanentFailure(exception) + } else { + this.handleFailure(exception) + } + } + try { + //TODO find how to implement a functional interface in kotlin + use it here & on AttachmentUploadJob (see TODO in DatabaseAttachmentProvider.kt on app side) + val listener = SessionServiceAttachment.ProgressListener { override fun onAttachmentProgress(total: Long, progress: Long) { EventBus.getDefault().postSticky(AttachmentProgressEvent(attachmentPointer, total, progress)) } } + FileServerAPI.shared.downloadFile(tempFile, attachmentPointer.url, MAX_ATTACHMENT_SIZE, listener) + } catch (e: Exception) { + return handleFailure(e) + } + + // Assume we're retrieving an attachment for an open group server if the digest is not set + var stream = if (!attachmentPointer.digest.isPresent) FileInputStream(tempFile) + else AttachmentCipherInputStream.createForAttachment(tempFile, attachmentPointer.size.or(0).toLong(), attachmentPointer.key?.toByteArray(), attachmentPointer.digest.get()) + + messageDataProvider.insertAttachment(tsIncomingMessageID, attachmentID, stream) + + } + + private fun handleSuccess() { + delegate?.handleJobSucceeded(this) + } + + private fun handlePermanentFailure(e: Exception) { + delegate?.handleJobFailedPermanently(this, e) + } + + private fun handleFailure(e: Exception) { + delegate?.handleJobFailed(this, e) + } + + private fun createTempFile(): File { + val file = File.createTempFile("push-attachment", "tmp", MessagingConfiguration.shared.context.cacheDir) + file.deleteOnExit() + return file } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt index 8b91c0f541..67e0ce0d49 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt @@ -112,6 +112,20 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S return Builder() } } + + /** + * An interface to receive progress information on upload/download of + * an attachment. + */ + interface ProgressListener { + /** + * Called on a progress change event. + * + * @param total The total amount to transmit/receive in bytes. + * @param progress The amount that has been transmitted/received in bytes thus far + */ + fun onAttachmentProgress(total: Long, progress: Long) + } } // matches values in AttachmentDatabase.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt index bf95e0f66c..43b2c8a6af 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/DotNetAPI.kt @@ -4,19 +4,19 @@ 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 okhttp3.* import org.session.libsession.messaging.MessagingConfiguration import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.messaging.fileserver.FileServerAPI +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachment 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.messages.SignalServiceAttachment 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 @@ -29,6 +29,9 @@ 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.io.File +import java.io.FileOutputStream +import java.io.OutputStream import java.util.* /** @@ -48,6 +51,8 @@ open class DotNetAPI { object DecryptionFailed : Error("Couldn't decrypt file.") object MaxFileSizeExceeded : Error("Maximum file size exceeded.") object TokenExpired: Error("Token expired.") // Session Android + + internal val isRetryable: Boolean = false } companion object { @@ -56,7 +61,7 @@ open class DotNetAPI { public data class UploadResult(val id: Long, val url: String, val digest: ByteArray?) - public fun getAuthToken(server: String): Promise { + fun getAuthToken(server: String): Promise { val storage = MessagingConfiguration.shared.storage val token = storage.getAuthToken(server) if (token != null) { return Promise.of(token) } @@ -175,6 +180,80 @@ open class DotNetAPI { return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters) } + // DOWNLOAD + + /** + * Blocks the calling thread. + */ + fun downloadFile(destination: File, url: String, maxSize: Int, listener: SessionServiceAttachment.ProgressListener?) { + val outputStream = FileOutputStream(destination) // Throws + var remainingAttempts = 4 + var exception: Exception? = null + while (remainingAttempts > 0) { + remainingAttempts -= 1 + try { + downloadFile(outputStream, url, maxSize, listener) + exception = null + break + } catch (e: Exception) { + exception = e + } + } + if (exception != null) { throw exception } + } + + /** + * Blocks the calling thread. + */ + fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SessionServiceAttachment.ProgressListener?) { + // We need to throw a PushNetworkException or NonSuccessfulResponseCodeException + // because the underlying Signal logic requires these to work correctly + val oldPrefixedHost = "https://" + HttpUrl.get(url).host() + var newPrefixedHost = oldPrefixedHost + if (oldPrefixedHost.contains(FileServerAPI.fileStorageBucketURL)) { + newPrefixedHost = FileServerAPI.shared.server + } + // Edge case that needs to work: https://file-static.lokinet.org/i1pNmpInq3w9gF3TP8TFCa1rSo38J6UM + // → https://file.getsession.org/loki/v1/f/XLxogNXVEIWHk14NVCDeppzTujPHxu35 + val fileID = url.substringAfter(oldPrefixedHost).substringAfter("/f/") + val sanitizedURL = "$newPrefixedHost/loki/v1/f/$fileID" + val request = Request.Builder().url(sanitizedURL).get() + try { + val serverPublicKey = if (newPrefixedHost.contains(FileServerAPI.shared.server)) FileServerAPI.fileServerPublicKey + else FileServerAPI.shared.getPublicKeyForOpenGroupServer(newPrefixedHost).get() + val json = OnionRequestAPI.sendOnionRequest(request.build(), newPrefixedHost, serverPublicKey, isJSONRequired = false).get() + val result = json["result"] as? String + if (result == null) { + Log.d("Loki", "Couldn't parse attachment from: $json.") + throw PushNetworkException("Missing response body.") + } + val body = Base64.decode(result) + if (body.size > maxSize) { + Log.d("Loki", "Attachment size limit exceeded.") + throw PushNetworkException("Max response size exceeded.") + } + val input = body.inputStream() + val buffer = ByteArray(32768) + var count = 0 + var bytes = input.read(buffer) + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + count += bytes + if (count > maxSize) { + Log.d("Loki", "Attachment size limit exceeded.") + throw PushNetworkException("Max response size exceeded.") + } + listener?.onAttachmentProgress(body.size.toLong(), count.toLong()) + bytes = input.read(buffer) + } + } catch (e: Exception) { + Log.d("Loki", "Couldn't download attachment due to error: $e.") + throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e) + } + } + + // UPLOAD + @Throws(PushNetworkException::class, NonSuccessfulResponseCodeException::class) fun uploadAttachment(server: String, attachment: PushAttachmentData): UploadResult { // This function mimics what Signal does in PushServiceSocket