diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ffd6173dba..defad7b175 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 428 -val canonicalVersionName = "1.28.2" +val canonicalVersionCode = 429 +val canonicalVersionName = "1.29.0" val postFixSize = 10 val abiPostFix = mapOf( @@ -402,6 +402,7 @@ dependencies { implementation(libs.phrase) implementation(libs.copper.flow) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.kovenant) implementation(libs.kovenant.android) implementation(libs.opencsv) diff --git a/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt index eac37db634..a42eaa2554 100644 --- a/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt +++ b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt @@ -12,6 +12,10 @@ class FirebaseTokenFetcher @Inject constructor(): TokenFetcher { override val token = MutableStateFlow(null) init { + fetchToken() + } + + private fun fetchToken() { FirebaseMessaging.getInstance() .token .addOnSuccessListener(this::onNewToken) @@ -23,5 +27,6 @@ class FirebaseTokenFetcher @Inject constructor(): TokenFetcher { override suspend fun resetToken() { FirebaseMessaging.getInstance().deleteToken().await() + fetchToken() } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a97d1610f8..0ddf065ec3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -432,10 +432,7 @@ - - - + android:exported="false"> = filesToKeep - .map { RemoteFileDownloadWorker.computeFileName(application, it) } + .map { AvatarDownloadManager.computeFileName(application, it) } .toSet() // 5) Delete everything not wanted in cache/remote_files - val files = RemoteFileDownloadWorker.listDownloadedFiles(application) + val files = AvatarDownloadManager.listDownloadedFiles(application) var deleted = 0 for (file in files) { if (file !in wantedFiles && file.delete()) deleted++ diff --git a/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java b/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java index 39b45bcdf1..77c66bb157 100644 --- a/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java +++ b/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java @@ -8,7 +8,6 @@ import com.annimon.stream.Stream; import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.Log; import java.io.File; import java.io.FileInputStream; @@ -22,7 +21,7 @@ /** * @deprecated We no longer use these address-based avatars. All avatars are now stored as sha256 of * urls encrypted locally. Look at {@link org.thoughtcrime.securesms.attachments.LocalEncryptedFileOutputStream}, - * {@link org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker}, + * {@link org.thoughtcrime.securesms.attachments.AvatarDownloadWorker}, * {@link org.thoughtcrime.securesms.glide.RecipientAvatarDownloadManager} for more information. * * Once the migration grace period is over, this class shall be removed. diff --git a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 3d4933e4a8..f16da48dae 100644 --- a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -30,7 +30,7 @@ interface MessageDataProvider { fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? fun getSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? - fun getScaledSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? + suspend fun getScaledSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? fun getSignalAttachmentPointer(attachmentId: Long): SignalServiceAttachmentPointer? fun setAttachmentState(attachmentState: AttachmentState, attachmentId: AttachmentId, messageID: Long) fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream) @@ -39,9 +39,8 @@ interface MessageDataProvider { fun isDeletedMessage(id: MessageId): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) fun handleFailedAttachmentUpload(attachmentId: Long) - fun getMessageForQuote(timestamp: Long, author: Address): Triple? + fun getMessageForQuote(threadId: Long, timestamp: Long, author: Address): Triple? fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List - fun getMessageBodyFor(timestamp: Long, author: String): String fun getAttachmentIDsFor(mmsMessageId: Long): List fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1192366bf0..f30b4aef98 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -92,7 +92,9 @@ interface StorageProtocol { fun addReceivedMessageTimestamp(timestamp: Long) fun removeReceivedMessageTimestamps(timestamps: Set) fun getAttachmentsForMessage(mmsMessageId: Long): List - fun getMessageBy(timestamp: Long, author: String): MessageRecord? + fun getMessageBy(threadId: Long, timestamp: Long, author: String): MessageRecord? + @Deprecated("We shouldn't be querying messages by timestamp alone. Use `getMessageBy` when possible ") + fun getMessageByTimestamp(timestamp: Long, author: String, getQuote: Boolean): MessageRecord? fun updateSentTimestamp(messageId: MessageId, newTimestamp: Long) fun markAsResyncing(messageId: MessageId) fun markAsSyncing(messageId: MessageId) @@ -181,7 +183,15 @@ interface StorageProtocol { attachments: List, runThreadUpdate: Boolean ): MessageId? - fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) + fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false, updateNotification: Boolean = true) + + /** + * Marks the conversation as read up to and including the message with [messageId]. It will + * take the reactions associated with messages prior to and including that message into account. + * + * It will not do anything if the last seen of this thread is already set in the future. + */ + fun markConversationAsReadUpToMessage(messageId: MessageId) fun markConversationAsUnread(threadId: Long) fun getLastSeen(threadId: Long): Long fun ensureMessageHashesAreSender(hashes: Set, sender: String, closedGroupId: String): Boolean diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt new file mode 100644 index 0000000000..de34f2407d --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt @@ -0,0 +1,18 @@ +package org.session.libsession.messaging.file_server + +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.session.libsession.utilities.serializable.HttpSerializer + +@Serializable +data class FileServer( + @Serializable(with = HttpSerializer::class) + val url: HttpUrl, + val ed25519PublicKeyHex: String +) { + constructor(url: String, ed25519PublicKeyHex: String) : this(url.toHttpUrl(), ed25519PublicKeyHex) +} + +val HttpUrl.isOfficial: Boolean + get() = host.endsWith(".getsession.org", ignoreCase = true) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index df019e57c5..e5128232f7 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -1,39 +1,44 @@ package org.session.libsession.messaging.file_server import android.util.Base64 +import kotlinx.coroutines.CancellationException +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BlindKeyAPI -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.map import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsession.database.StorageProtocol import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.util.DateUtils.Companion.asEpochSeconds import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -object FileServerApi { +@Singleton +class FileServerApi @Inject constructor( + private val storage: StorageProtocol, +) { - private const val FILE_SERVER_PUBLIC_KEY = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - const val FILE_SERVER_URL = "http://filev2.getsession.org" - const val MAX_FILE_SIZE = 10_000_000 // 10 MB + companion object { + const val MAX_FILE_SIZE = 10_000_000 // 10 MB - val fileServerUrl: HttpUrl by lazy { FILE_SERVER_URL.toHttpUrl() } - - val FILE_SERVER_FILE_URL_PATTERN: Pattern by lazy { - Pattern.compile("^https?://filev2\\.getsession\\.org/files?/(.+)$", Pattern.CASE_INSENSITIVE) + val DEFAULT_FILE_SERVER: FileServer = FileServer( + url = "http://filev2.getsession.org", + ed25519PublicKeyHex = "b8eef9821445ae16e2e97ef8aa6fe782fd11ad5253cd6723b281341dba22e371" + ) } sealed class Error(message: String) : Exception(message) { @@ -42,13 +47,14 @@ object FileServerApi { object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") } - data class Request( - val verb: HTTP.Verb, - val endpoint: String, - val queryParameters: Map = mapOf(), - val parameters: Any? = null, - val headers: Map = mapOf(), - val body: ByteArray? = null, + private data class Request( + val fileServer: FileServer, + val verb: HTTP.Verb, + val endpoint: String, + val queryParameters: Map = mapOf(), + val parameters: Any? = null, + val headers: Map = mapOf(), + val body: ByteArray? = null, /** * Always `true` under normal circumstances. You might want to disable * this when running over Lokinet. @@ -56,25 +62,21 @@ object FileServerApi { val useOnionRouting: Boolean = true ) - fun getFileIdFromUrl(url: String): String? { - val matcher = FILE_SERVER_FILE_URL_PATTERN.matcher(url) - return if (matcher.matches()) { - matcher.group(1) - } else { - null - } - } - private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { - if (body != null) return RequestBody.create("application/octet-stream".toMediaType(), body) + if (body != null) return body.toRequestBody( + "application/octet-stream".toMediaType(), + 0, + body.size + ) + if (parameters == null) return null val parametersAsJSON = JsonUtil.toJson(parameters) - return RequestBody.create("application/json".toMediaType(), parametersAsJSON) + return parametersAsJSON.toRequestBody("application/json".toMediaType()) } - private fun send(request: Request): Promise { - val urlBuilder = fileServerUrl + private suspend fun send(request: Request): SendResponse { + val urlBuilder = request.fileServer.url .newBuilder() .addPathSegments(request.endpoint) if (request.verb == HTTP.Verb.GET) { @@ -87,35 +89,50 @@ object FileServerApi { .headers(request.headers.toHeaders()) when (request.verb) { HTTP.Verb.GET -> requestBuilder.get() - HTTP.Verb.PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) - HTTP.Verb.POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) + HTTP.Verb.PUT -> requestBuilder.put(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) + HTTP.Verb.POST -> requestBuilder.post(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) } return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), FILE_SERVER_URL, FILE_SERVER_PUBLIC_KEY).map { - val body = it.body ?: throw Error.ParsingFailed + try { + val response = OnionRequestAPI.sendOnionRequest( + request = requestBuilder.build(), + server = request.fileServer.url.host, + x25519PublicKey = + Hex.toStringCondensed( + Curve25519.pubKeyFromED25519(Hex.fromStringCondensed(request.fileServer.ed25519PublicKeyHex)) + ) + ).await() + + check(response.code in 200..299) { + "Error response from file server: ${response.code}" + } + + val body = response.body ?: throw Error.ParsingFailed SendResponse( body = body, - headers = it.info["headers"] as? Map + headers = response.info["headers"] as? Map ) - }.fail { e -> - when (e) { - // No need for the stack trace for HTTP errors - is HTTP.HTTPRequestFailedException -> Log.e("Loki", "File server request failed due to error: ${e.message}") - else -> Log.e("Loki", "File server request failed", e) - } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("Loki", "File server request failed", e) + throw e } } else { - Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) + error("It's currently not allowed to send non onion routed requests.") } } - fun upload( + suspend fun upload( file: ByteArray, + usedDeterministicEncryption: Boolean, + fileServer: FileServer, customExpiresDuration: Duration? = null - ): Promise { + ): UploadResult { val request = Request( + fileServer = fileServer, verb = HTTP.Verb.POST, endpoint = "file", body = file, @@ -127,28 +144,154 @@ object FileServerApi { } } ) - return send(request).map { response -> - val json = JsonUtil.fromJson(response.body, Map::class.java) - val id = json["id"]!!.toString() - val expiresEpochSeconds = (json.getOrDefault("expires", null) as? Number)?.toLong() + val response = send(request) + val json = JsonUtil.fromJson(response.body, Map::class.java) + val id = json["id"]!!.toString() + val expiresEpochSeconds = (json.getOrDefault("expires", null) as? Number)?.toLong() - UploadResult( + return UploadResult( + fileId = id, + fileUrl = buildAttachmentUrl( fileId = id, - fileUrl = fileServerUrl.newBuilder() - .addPathSegment("file") - .addPathSegments(id) - .build() - .toString(), - expires = expiresEpochSeconds?.asEpochSeconds() - ) - } + fileServer = fileServer, + usesDeterministicEncryption = usedDeterministicEncryption + ).toString(), + expires = expiresEpochSeconds?.asEpochSeconds() + ) } - fun download(file: String): Promise { - val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file") + suspend fun download( + fileId: String, + fileServer: FileServer = DEFAULT_FILE_SERVER + ): SendResponse { + val request = Request( + fileServer = fileServer, + verb = HTTP.Verb.GET, + endpoint = "file/$fileId" + ) return send(request) } + suspend fun renew(fileId: String, + customTtl: Duration? = null, + fileServer: FileServer = DEFAULT_FILE_SERVER) { + val resp = send(Request( + fileServer = fileServer, + verb = HTTP.Verb.POST, + endpoint = "file/$fileId/extend", + headers = customTtl?.let { + buildMap { + "X-FS-TTL" to it.inWholeSeconds.toString() + } + } ?: mapOf() + )) + + resp.expires + } + + fun buildAttachmentUrl( + fileId: String, + fileServer: FileServer, + usesDeterministicEncryption: Boolean + ): HttpUrl { + val urlFragment = sequenceOf( + "d".takeIf { usesDeterministicEncryption }, + if (!fileServer.url.isOfficial || fileServer.ed25519PublicKeyHex != DEFAULT_FILE_SERVER.ed25519PublicKeyHex) { + "p=${fileServer.ed25519PublicKeyHex}" + } else { + null + } + ).filterNotNull() + .joinToString(separator = "&") + + return fileServer.url + .newBuilder() + .addPathSegment("file") + .addPathSegment(fileId) + .fragment(urlFragment.takeIf { it.isNotBlank() }) + .build() + } + + data class URLParseResult( + val fileId: String, + val fileServer: FileServer, + val usesDeterministicEncryption: Boolean + ) + + fun parseAttachmentUrl(url: HttpUrl): URLParseResult { + check(url.pathSegments.size == 2) { + "Invalid URL: requiring exactly 2 path segments" + } + + check(url.pathSegments[0] == "file") { + "Invalid URL: first path segment must be 'file'" + } + + val id = url.pathSegments[1] + check(id.isNotBlank()) { + "Invalid URL: id must not be blank" + } + + var deterministicEncryption = false + var fileServerPubKeyHex: String? = null + + url.fragment + .orEmpty() + .splitToSequence('&') + .forEach { fragment -> + when { + fragment == "d" || fragment == "d=" -> deterministicEncryption = true + fragment.startsWith("p=", ignoreCase = true) -> { + fileServerPubKeyHex = fragment.substringAfter("p=").takeIf { it.isNotBlank() } + } + } + } + + val fileServerUrl = url.newBuilder() + .removePathSegment(0) // remove "file" + .removePathSegment(0) // remove id + .fragment(null) // remove fragment + .build() + + when { + !fileServerPubKeyHex.isNullOrEmpty() -> { + // We'll use the public key we get from the URL + return URLParseResult( + fileId = id, + fileServer = FileServer(url = fileServerUrl, ed25519PublicKeyHex = fileServerPubKeyHex), + usesDeterministicEncryption = deterministicEncryption + ) + } + + fileServerUrl == DEFAULT_FILE_SERVER.url -> { + // We'll use the default file server + return URLParseResult( + fileId = id, + fileServer = DEFAULT_FILE_SERVER, + usesDeterministicEncryption = deterministicEncryption + ) + } + + fileServerUrl.isOfficial -> { + // We don't have a public key, but given it's an official file server, + // we can use the default public key + return URLParseResult( + fileId = id, + fileServer = FileServer( + url = fileServerUrl, + ed25519PublicKeyHex = DEFAULT_FILE_SERVER.ed25519PublicKeyHex + ), + usesDeterministicEncryption = deterministicEncryption + ) + } + + else -> { + // We don't have a public key, and it's not the default file server + throw Error.InvalidURL + } + } + } + /** * Returns the current version of session * This is effectively proxying (and caching) the response from the github release @@ -159,9 +302,9 @@ object FileServerApi { * * https://github.com/session-foundation/session-file-server/blob/dev/doc/api.yaml#L119 */ - suspend fun getClientVersion(): VersionData { + suspend fun getClientVersion(fileServer: FileServer = DEFAULT_FILE_SERVER): VersionData { // Generate the auth signature - val secretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.data + val secretKey = storage.getUserED25519KeyPair()?.secretKey?.data ?: throw (Error.NoEd25519KeyPair) val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) @@ -172,6 +315,7 @@ object FileServerApi { val blindedPkHex = "07" + blindedKeys.pubKey.data.toHexString() val request = Request( + fileServer = fileServer, verb = HTTP.Verb.GET, endpoint = "session_version", queryParameters = mapOf("platform" to "android"), @@ -183,7 +327,7 @@ object FileServerApi { ) // transform the promise into a coroutine - val result = send(request).await() + val result = send(request) // map out the result return JsonUtil.fromJson(result.body, Map::class.java).let { diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt index 34fde55a03..7c3584917f 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt @@ -1,14 +1,16 @@ package org.session.libsession.messaging.groups -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -import java.util.concurrent.atomic.AtomicLong +import org.thoughtcrime.securesms.dependencies.ManagerScope +import javax.inject.Inject +import javax.inject.Singleton private const val TAG = "GroupScope" @@ -19,8 +21,11 @@ private const val TAG = "GroupScope" * It's probably harmful if you apply the scope on message retrieval, as normally the message retriveal * doesn't have any order requirement and it will likly slow down usual group operations. */ -class GroupScope(private val scope: CoroutineScope = GlobalScope) { - private val tasksByGroupId = hashMapOf>>() +@Singleton +class GroupScope @Inject constructor( + @param:ManagerScope private val scope: CoroutineScope +) { + private val groupSemaphores = hashMapOf() /** * Launch a coroutine in a group context. The coroutine will be executed sequentially @@ -50,60 +55,15 @@ class GroupScope(private val scope: CoroutineScope = GlobalScope) { * See [launch] for more details. */ fun async(groupId: AccountId, debugName: String, block: suspend () -> T) : Deferred { - val completion = CompletableDeferred() - - synchronized(tasksByGroupId) { - val tasks = tasksByGroupId.getOrPut(groupId) { ArrayDeque() } - - val task = Task(groupId, debugName, block, completion) - tasks.addLast(task) - - Log.d(TAG, "Added $task to queue, queue size: ${tasks.size}") - - // If this is the first task in the queue, start it directly (otherwise the next will be started by the previous task) - if (tasks.size == 1) { - scope.launch { - task.run() - } + return scope.async { + val semaphore = synchronized(groupSemaphores) { + groupSemaphores.getOrPut(groupId) { Semaphore(1) } } - } - - return completion - } - - private val taskIdSeq = AtomicLong(0) - - private inner class Task(val groupId: AccountId, val debugName: String, val block: suspend () -> T, val completion: CompletableDeferred) { - private val id = taskIdSeq.getAndIncrement() - suspend fun run() { - Log.d(TAG, "Task started: $this") - try { - completion.complete(block()) - } catch (e: Throwable) { - completion.completeExceptionally(e) - } finally { - Log.d(TAG, "Task completed: $this") - // Remove self from the queue and start the next task - synchronized(tasksByGroupId) { - val tasks = tasksByGroupId[groupId] - require(tasks != null && tasks.firstOrNull() == this) { "Task is not the first in the queue: $this" } - tasks.removeFirst() - - if (tasks.isEmpty()) { - tasksByGroupId.remove(groupId) - } else { - val nextTask = tasks.first() - scope.launch { - nextTask.run() - } - } - } + semaphore.withPermit { + Log.d(TAG, "Starting group-scoped task '$debugName' for group $groupId") + block() } } - - override fun toString(): String { - return "Task($debugName, id=$id)" - } } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index f02e548701..a6747b3b10 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -5,9 +5,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState @@ -17,26 +19,21 @@ import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio -import org.session.libsession.utilities.DownloadUtilities import org.session.libsession.utilities.InputStreamMediaDataSource -import org.session.libsignal.streams.AttachmentCipherInputStream +import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.database.model.MessageId -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.InputStream class AttachmentDownloadJob @AssistedInject constructor( @Assisted("attachmentID") val attachmentID: Long, @Assisted val mmsMessageId: Long, - @param:ApplicationContext private val context: Context, private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, - private val recipientRepository: RecipientRepository, + private val attachmentProcessor: AttachmentProcessor, + private val fileServerApi: FileServerApi, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -100,7 +97,8 @@ class AttachmentDownloadJob @AssistedInject constructor( } else if (exception == Error.NoAttachment || exception == Error.NoThread || exception == Error.NoSender - || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400)) { + || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400) + || exception is NonRetryableException) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, mmsMessageId) @@ -149,7 +147,6 @@ class AttachmentDownloadJob @AssistedInject constructor( val threadRecipient = storage.getRecipientForThread(threadID) - var tempFile: File? = null var attachment: DatabaseAttachment? = null try { @@ -160,31 +157,57 @@ class AttachmentDownloadJob @AssistedInject constructor( return } messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.mmsMessageId) - val downloadedData = if (threadRecipient?.address !is Address.Community) { + + val decrypted = if (threadRecipient?.address !is Address.Community) { Log.d("AttachmentDownloadJob", "downloading normal attachment") - DownloadUtilities.downloadFromFileServer(attachment.url).body + val r = runCatching { fileServerApi.parseAttachmentUrl(attachment.url.toHttpUrl()) } + .recover { throw NonRetryableException("Invalid file server URL", it) } + .getOrThrow() + + val key = requireNotNull(attachment.key) { + throw NonRetryableException("Missing attachment key") + }.let(Base64::decode) + + val cipherText = fileServerApi.download( + fileId = r.fileId, + fileServer = r.fileServer + ).body + + runCatching { + if (r.usesDeterministicEncryption) { + attachmentProcessor.decryptDeterministically( + ciphertext = cipherText, + key = key + ) + } else { + attachmentProcessor.decryptAttachmentLegacy( + ciphertext = cipherText, + key = key, + digest = attachment.digest + ) + } + }.recover { throw NonRetryableException("Decryption failed", it) } + .getOrThrow() } else { Log.d("AttachmentDownloadJob", "downloading open group attachment") val url = attachment.url.toHttpUrlOrNull()!! val fileID = url.pathSegments.last() - OpenGroupApi.download(fileID, room = threadRecipient.address.room, server = threadRecipient.address.serverUrl).await() - } - - tempFile = createTempFile().also { file -> - FileOutputStream(file).use { - it.write(downloadedData.data, downloadedData.offset, downloadedData.len) - } + OpenGroupApi.download(fileID, room = threadRecipient.address.room, server = threadRecipient.address.serverUrl) } Log.d("AttachmentDownloadJob", "getting input stream") - val inputStream = getInputStream(tempFile, attachment) Log.d("AttachmentDownloadJob", "inserting attachment") - messageDataProvider.insertAttachment(mmsMessageId, attachment.attachmentId, inputStream) + messageDataProvider.insertAttachment( + messageId = mmsMessageId, + attachmentId = attachment.attachmentId, + stream = decrypted.inputStream() + ) + if (attachment.contentType.startsWith("audio/")) { // process the duration try { - InputStreamMediaDataSource(getInputStream(tempFile, attachment)).use { mediaDataSource -> + InputStreamMediaDataSource(decrypted.inputStream()).use { mediaDataSource -> val durationMs = (DecodedAudio.create(mediaDataSource).totalDurationMicroseconds / 1000.0).toLong() messageDataProvider.updateAudioAttachmentDuration( attachment.attachmentId, @@ -197,27 +220,14 @@ class AttachmentDownloadJob @AssistedInject constructor( } } Log.d("AttachmentDownloadJob", "deleting tempfile") - tempFile.delete() Log.d("AttachmentDownloadJob", "succeeding job") handleSuccess(dispatcherName) } catch (e: Exception) { Log.e("AttachmentDownloadJob", "Error processing attachment download", e) - tempFile?.delete() return handleFailure(e,attachment?.attachmentId) } } - private fun getInputStream(tempFile: File, attachment: DatabaseAttachment): InputStream { - // Assume we're retrieving an attachment for an open group server if the digest is not set - return if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) { - Log.d("AttachmentDownloadJob", "getting input stream with no attachment digest") - FileInputStream(tempFile) - } else { - Log.d("AttachmentDownloadJob", "getting input stream with attachment digest") - AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest) - } - } - private fun handleSuccess(dispatcherName: String) { Log.w("AttachmentDownloadJob", "Attachment downloaded successfully.") delegate?.handleJobSucceeded(this, dispatcherName) @@ -231,17 +241,11 @@ class AttachmentDownloadJob @AssistedInject constructor( delegate?.handleJobFailed(this, dispatcherName, e) } - private fun createTempFile(): File { - val file = File.createTempFile("push-attachment", "tmp", context.cacheDir) - file.deleteOnExit() - return file - } - override fun serialize(): Data { return Data.Builder() .putLong(ATTACHMENT_ID_KEY, attachmentID) .putLong(TS_INCOMING_MESSAGE_ID_KEY, mmsMessageId) - .build(); + .build() } override fun getFactoryKey(): String { diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 20a497eaba..bd31a3c3f1 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -6,9 +6,7 @@ import com.esotericsoftware.kryo.io.Output import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.map -import okio.Buffer +import network.loki.messenger.libsession_util.encrypt.Attachments import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServerApi @@ -21,16 +19,11 @@ import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UploadResult import org.session.libsignal.messages.SignalServiceAttachmentStream -import org.session.libsignal.streams.AttachmentCipherOutputStream -import org.session.libsignal.streams.AttachmentCipherOutputStreamFactory -import org.session.libsignal.streams.DigestingRequestBody -import org.session.libsignal.streams.PaddingInputStream -import org.session.libsignal.streams.PlaintextOutputStreamFactory import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.PushAttachmentData -import org.session.libsignal.utilities.Util +import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.database.ThreadDatabase class AttachmentUploadJob @AssistedInject constructor( @@ -42,6 +35,9 @@ class AttachmentUploadJob @AssistedInject constructor( private val messageDataProvider: MessageDataProvider, private val messageSendJobFactory: MessageSendJob.Factory, private val threadDatabase: ThreadDatabase, + private val attachmentProcessor: AttachmentProcessor, + private val preferences: TextSecurePreferences, + private val fileServerApi: FileServerApi, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -75,14 +71,29 @@ class AttachmentUploadJob @AssistedInject constructor( RuntimeException("Thread doesn't exist")) if (threadAddress is Address.Community) { - val keyAndResult = upload(attachment, threadAddress.serverUrl, false) { - OpenGroupApi.upload(it, threadAddress.room, threadAddress.serverUrl) + val keyAndResult = upload( + attachment = attachment, + encrypt = false + ) { data, _ -> + val id = OpenGroupApi.upload(data, threadAddress.room, threadAddress.serverUrl) + id to "${threadAddress.serverUrl}/file/$id" } handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } else { - val keyAndResult = upload(attachment, FileServerApi.FILE_SERVER_URL, true) { - FileServerApi.upload(it).map { it.fileId } + val fileServer = preferences.alternativeFileServer ?: FileServerApi.DEFAULT_FILE_SERVER + val keyAndResult = upload( + attachment = attachment, + encrypt = true + ) { data, isDeterministicallyEncrypted -> + val result = fileServerApi.upload( + file = data, + usedDeterministicEncryption = isDeterministicallyEncrypted, + fileServer = fileServer + ) + + result.fileId to result.fileUrl } + handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } } catch (e: java.lang.Exception) { @@ -94,36 +105,59 @@ class AttachmentUploadJob @AssistedInject constructor( } } - private suspend fun upload(attachment: SignalServiceAttachmentStream, server: String, encrypt: Boolean, upload: (ByteArray) -> Promise): Pair { - // Key - val key = if (encrypt) Util.getSecretBytes(64) else ByteArray(0) - // Length - val rawLength = attachment.length - val length = if (encrypt) { - val paddedLength = PaddingInputStream.getPaddedSize(rawLength) - AttachmentCipherOutputStream.getCiphertextLength(paddedLength) - } else { - attachment.length + private suspend fun upload(attachment: SignalServiceAttachmentStream, + encrypt: Boolean, + // Returning pair of fileId and the final file URL + upload: suspend ( + data: ByteArray, + isDeterministicallyEncrypted: Boolean, + ) -> Pair + ): Pair { + val input = attachment.inputStream.use { + it.readBytes() + } + + val key: ByteArray + val dataToUpload: ByteArray + val digest: ByteArray? + val deterministicallyEncrypted: Boolean + + when { + encrypt && preferences.forcesDeterministicAttachmentEncryption -> { + deterministicallyEncrypted = true + val result = attachmentProcessor.encryptDeterministically( + plaintext = input, + domain = Attachments.Domain.Attachment + ) + key = result.key + dataToUpload = result.ciphertext + digest = null + } + + encrypt -> { + deterministicallyEncrypted = false + val result = attachmentProcessor.encryptAttachmentLegacy(plaintext = input) + key = result.first.key + dataToUpload = result.first.ciphertext + digest = result.second + } + + else -> { + deterministicallyEncrypted = false + key = byteArrayOf() + dataToUpload = input + digest = attachmentProcessor.digest(dataToUpload) + } } - // In & out streams - // PaddingInputStream adds padding as data is read out from it. AttachmentCipherOutputStream - // encrypts as it writes data. - val inputStream = if (encrypt) PaddingInputStream(attachment.inputStream, rawLength) else attachment.inputStream - val outputStreamFactory = if (encrypt) AttachmentCipherOutputStreamFactory(key) else PlaintextOutputStreamFactory() - // Create a digesting request body but immediately read it out to a buffer. Doing this makes - // it easier to deal with inputStream and outputStreamFactory. - val pad = PushAttachmentData(attachment.contentType, inputStream, length, outputStreamFactory) - val contentType = "application/octet-stream" - val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, contentType, pad.dataSize) - Log.d("Loki", "File size: ${length.toDouble() / 1000} kb.") - val b = Buffer() - drb.writeTo(b) - val data = b.readByteArray() - // Upload the data - val id = upload(data).await() - val digest = drb.transmittedDigest + + val (id, url) = upload(dataToUpload, deterministicallyEncrypted) + // Return - return Pair(key, UploadResult(id, "${server}/file/$id", digest)) + return Pair(key, UploadResult( + id = id, + url = url, + digest = digest, + )) } private fun handleSuccess(dispatcherName: String, attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index e94134bd9a..1a4c5cc9f9 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -16,7 +16,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.getGroup import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage @@ -78,7 +77,6 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< } MessageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) - .await() } } } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 44395deca3..238b7edb04 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -20,7 +20,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsignal.utilities.AccountId @@ -90,16 +89,16 @@ class MessageSendJob @AssistedInject constructor( val isSync = destination is Destination.Contact && destination.publicKey == storage.getUserPublicKey() try { - withTimeout(20_000L) { - // Shouldn't send message to group when the group has no keys available - if (destination is Destination.ClosedGroup) { + // Shouldn't send message to group when the group has no keys available + if (destination is Destination.ClosedGroup) { + withTimeout(20_000L) { configFactory .waitForGroupEncryptionKeys(AccountId(destination.publicKey)) } - - MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync).await() } + MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) + this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) } catch (e: HTTP.HTTPRequestFailedException) { diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt index 4d3f43f36f..d7a2cb82dc 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt @@ -73,9 +73,12 @@ class Attachment { .setContentType(attachment.contentType) .setId(attachment.id.toString().toLongOrNull() ?: 0L) .setKey(ByteString.copyFrom(attachment.key)) - .setDigest(ByteString.copyFrom(attachment.digest.get())) .setSize(attachment.size.get()) .setUrl(attachment.url) + + if (attachment.digest.isPresent) { + builder.setDigest(ByteString.copyFrom(attachment.digest.get())) + } // Filenames are now mandatory for picked/shared files, Giphy GIFs, and captured photos. // The images associated with LinkPreviews don't have a "given name" so we'll use the diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 7692f118ce..3d650db223 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -5,17 +5,14 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.annotation.JsonNaming -import com.fasterxml.jackson.databind.type.TypeFactory -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.util.BlindKeyAPI -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.map import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType @@ -24,7 +21,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64.encodeBytes @@ -40,9 +36,6 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import java.security.SecureRandom import java.util.concurrent.TimeUnit -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set object OpenGroupApi { val defaultRooms = MutableSharedFlow>(replay = 1) @@ -215,12 +208,16 @@ object OpenGroupApi { val index: Long = 0 ) + @Serializable data class AddReactionResponse( + @SerialName("seqno") val seqNo: Long, val added: Boolean ) + @Serializable data class DeleteReactionResponse( + @SerialName("seqno") val seqNo: Long, val removed: Boolean ) @@ -283,24 +280,23 @@ object OpenGroupApi { return RequestBody.create("application/json".toMediaType(), parametersAsJSON) } - private fun getResponseBody( + private suspend fun getResponseBody( request: Request, signRequest: Boolean = true, serverPubKeyHex: String? = null - ): Promise { - return send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex).map { response -> - response.body ?: throw Error.ParsingFailed - } + ): ByteArraySlice { + val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) + + return response.body ?: throw Error.ParsingFailed } - private fun getResponseBodyJson( + private suspend fun getResponseBodyJson( request: Request, signRequest: Boolean = true, serverPubKeyHex: String? = null - ): Promise, Exception> { - return send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex).map { - JsonUtil.fromJson(it.body, Map::class.java) - } + ): Map<*, *> { + val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) + return JsonUtil.fromJson(response.body, Map::class.java) } suspend fun getOrFetchServerCapabilities(server: String): List { @@ -313,14 +309,15 @@ object OpenGroupApi { val fetched = getCapabilities(server, serverPubKeyHex = defaultServerPublicKey.takeIf { server == defaultServer } - ).await() + ) storage.setServerCapabilities(server, fetched.capabilities) return fetched.capabilities } - private fun send(request: Request, signRequest: Boolean, serverPubKeyHex: String? = null): Promise { - request.server.toHttpUrlOrNull() ?: return Promise.ofFail(Error.InvalidURL) + private suspend fun send(request: Request, signRequest: Boolean, serverPubKeyHex: String? = null): OnionResponse { + request.server.toHttpUrlOrNull() ?: throw(Error.InvalidURL) + val urlBuilder = StringBuilder("${request.server}/${request.endpoint.value}") if (request.verb == GET && request.queryParameters.isNotEmpty()) { urlBuilder.append("?") @@ -329,113 +326,109 @@ object OpenGroupApi { } } - suspend fun execute(): OnionResponse { - val serverPublicKey = serverPubKeyHex - ?: MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) - ?: throw Error.NoPublicKey - val urlRequest = urlBuilder.toString() - - val headers = if (signRequest) { - val serverCapabilities = getOrFetchServerCapabilities(request.server) - - val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() - ?: throw Error.NoEd25519KeyPair - - val headers = request.headers.toMutableMap() - val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - val bodyHash = if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - Hash.hash64(parameterBytes) - } else if (request.body != null) { - Hash.hash64(request.body) - } else { - byteArrayOf() - } + val serverPublicKey = serverPubKeyHex + ?: MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) + ?: throw Error.NoPublicKey + val urlRequest = urlBuilder.toString() + + val headers = if (signRequest) { + val serverCapabilities = getOrFetchServerCapabilities(request.server) + + val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() + ?: throw Error.NoEd25519KeyPair + + val headers = request.headers.toMutableMap() + val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } + val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) + val bodyHash = if (request.parameters != null) { + val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() + Hash.hash64(parameterBytes) + } else if (request.body != null) { + Hash.hash64(request.body) + } else { + byteArrayOf() + } - val messageBytes = Hex.fromStringCondensed(serverPublicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - - val signature: ByteArray - val pubKey: String - - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - pubKey = AccountId( - IdPrefix.BLINDED, - BlindKeyAPI.blind15KeyPair( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey) - ).pubKey.data - ).hexString - - try { - signature = BlindKeyAPI.blind15Sign( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = serverPublicKey, - message = messageBytes - ) - } catch (e: Exception) { - throw Error.SigningFailed - } - } else { - pubKey = AccountId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.pubKey.data - ).hexString - - signature = ED25519.sign( - ed25519PrivateKey = ed25519KeyPair.secretKey.data, + val messageBytes = Hex.fromStringCondensed(serverPublicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus("/${request.endpoint.value}".toByteArray()) + .plus(bodyHash) + + val signature: ByteArray + val pubKey: String + + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + pubKey = AccountId( + IdPrefix.BLINDED, + BlindKeyAPI.blind15KeyPair( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey) + ).pubKey.data + ).hexString + + try { + signature = BlindKeyAPI.blind15Sign( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = serverPublicKey, message = messageBytes ) + } catch (e: Exception) { + throw Error.SigningFailed } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) - headers - } else { - request.headers - } - - val requestBuilder = okhttp3.Request.Builder() - .url(urlRequest) - .headers(headers.toHeaders()) - when (request.verb) { - GET -> requestBuilder.get() - PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) - POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) - DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) - } - if (!request.room.isNullOrEmpty()) { - requestBuilder.header("Room", request.room) - } - return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> - when (e) { - // No need for the stack trace for HTTP errors - is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") - else -> Log.e("SOGS", "Failed onion request", e) - } - }.await() } else { - throw IllegalStateException("It's currently not allowed to send non onion routed requests.") + pubKey = AccountId( + IdPrefix.UN_BLINDED, + ed25519KeyPair.pubKey.data + ).hexString + + signature = ED25519.sign( + ed25519PrivateKey = ed25519KeyPair.secretKey.data, + message = messageBytes + ) } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) + headers + } else { + request.headers } - return GlobalScope.asyncPromise(block=::execute) + val requestBuilder = okhttp3.Request.Builder() + .url(urlRequest) + .headers(headers.toHeaders()) + when (request.verb) { + GET -> requestBuilder.get() + PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) + POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) + DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) + } + if (!request.room.isNullOrEmpty()) { + requestBuilder.header("Room", request.room) + } + return if (request.useOnionRouting) { + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> + when (e) { + // No need for the stack trace for HTTP errors + is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") + else -> Log.e("SOGS", "Failed onion request", e) + } + }.await() + } else { + throw IllegalStateException("It's currently not allowed to send non onion routed requests.") + } } - fun downloadOpenGroupProfilePicture( + suspend fun downloadOpenGroupProfilePicture( server: String, roomID: String, imageId: String, signRequest: Boolean = true, serverPubKeyHex: String? = null, - ): Promise { + ): ByteArraySlice { val request = Request( verb = GET, room = roomID, @@ -446,7 +439,7 @@ object OpenGroupApi { } // region Upload/Download - fun upload(file: ByteArray, room: String, server: String): Promise { + suspend fun upload(file: ByteArray, room: String, server: String): String { val request = Request( verb = POST, room = room, @@ -458,12 +451,11 @@ object OpenGroupApi { "Content-Type" to "application/octet-stream" ) ) - return getResponseBodyJson(request, signRequest = true).map { json -> - json["id"]?.toString() ?: throw Error.ParsingFailed - } + val json = getResponseBodyJson(request, signRequest = true) + return json["id"]?.toString() ?: throw Error.ParsingFailed } - fun download(fileId: String, room: String, server: String): Promise { + suspend fun download(fileId: String, room: String, server: String): ByteArraySlice { val request = Request( verb = GET, room = room, @@ -475,15 +467,15 @@ object OpenGroupApi { // endregion // region Sending - fun sendMessage( + suspend fun sendMessage( message: OpenGroupMessage, room: String, server: String, whisperTo: List? = null, whisperMods: Boolean? = null, fileIds: List? = null - ): Promise { - val signedMessage = message.sign(server) ?: return Promise.ofFail(Error.SigningFailed) + ): OpenGroupMessage { + val signedMessage = message.sign(server) ?:throw Error.SigningFailed val parameters = signedMessage.toJSON().toMutableMap() // add file IDs if there are any (from attachments) @@ -498,18 +490,18 @@ object OpenGroupApi { endpoint = Endpoint.RoomMessage(room), parameters = parameters ) - return getResponseBodyJson(request, signRequest = true).map { json -> - @Suppress("UNCHECKED_CAST") val rawMessage = json as? Map - ?: throw Error.ParsingFailed - val result = OpenGroupMessage.fromJSON(rawMessage) ?: throw Error.ParsingFailed - val storage = MessagingModuleConfiguration.shared.storage - storage.addReceivedMessageTimestamp(result.sentTimestamp) - result - } + val json = getResponseBodyJson(request, signRequest = true) + @Suppress("UNCHECKED_CAST") val rawMessage = json as? Map + ?: throw Error.ParsingFailed + val result = OpenGroupMessage.fromJSON(rawMessage) ?: throw Error.ParsingFailed + val storage = MessagingModuleConfiguration.shared.storage + storage.addReceivedMessageTimestamp(result.sentTimestamp) + return result } // endregion - fun addReaction(room: String, server: String, messageId: Long, emoji: String): Promise { + @OptIn(ExperimentalSerializationApi::class) + suspend fun addReaction(room: String, server: String, messageId: Long, emoji: String): AddReactionResponse { val request = Request( verb = PUT, room = room, @@ -517,89 +509,51 @@ object OpenGroupApi { endpoint = Endpoint.Reaction(room, messageId, emoji), parameters = emptyMap() ) - val pendingReaction = PendingReaction(server, room, messageId, emoji, true) - return getResponseBody(request, signRequest = true).map { response -> - JsonUtil.fromJson(response, AddReactionResponse::class.java).also { - val index = pendingReactions.indexOf(pendingReaction) - pendingReactions[index].seqNo = it.seqNo - } - } + + val response = getResponseBody(request, signRequest = true) + val reaction: AddReactionResponse = response.inputStream().use( MessagingModuleConfiguration.shared.json::decodeFromStream) + + return reaction } - fun deleteReaction(room: String, server: String, messageId: Long, emoji: String): Promise { + @OptIn(ExperimentalSerializationApi::class) + suspend fun deleteReaction(room: String, server: String, messageId: Long, emoji: String): DeleteReactionResponse { val request = Request( verb = DELETE, room = room, server = server, endpoint = Endpoint.Reaction(room, messageId, emoji) ) - val pendingReaction = PendingReaction(server, room, messageId, emoji, true) - return getResponseBody(request, signRequest = true).map { response -> - JsonUtil.fromJson(response, DeleteReactionResponse::class.java).also { - val index = pendingReactions.indexOf(pendingReaction) - pendingReactions[index].seqNo = it.seqNo - } - } + + val response = getResponseBody(request, signRequest = true) + val reaction: DeleteReactionResponse = MessagingModuleConfiguration.shared.json.decodeFromStream(response.inputStream()) + + return reaction } - fun deleteAllReactions(room: String, server: String, messageId: Long, emoji: String): Promise { + suspend fun deleteAllReactions(room: String, server: String, messageId: Long, emoji: String): DeleteAllReactionsResponse { val request = Request( verb = DELETE, room = room, server = server, endpoint = Endpoint.ReactionDelete(room, messageId, emoji) ) - return getResponseBody(request, signRequest = true).map { response -> - JsonUtil.fromJson(response, DeleteAllReactionsResponse::class.java) - } + val response = getResponseBody(request, signRequest = true) + return JsonUtil.fromJson(response, DeleteAllReactionsResponse::class.java) } // endregion // region Message Deletion - @JvmStatic - fun deleteMessage(serverID: Long, room: String, server: String): Promise { + suspend fun deleteMessage(serverID: Long, room: String, server: String) { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) - return send(request, signRequest = true).map { - Log.d("Loki", "Message deletion successful.") - } + send(request, signRequest = true) + Log.d("Loki", "Message deletion successful.") } - fun getDeletedMessages( - room: String, - server: String - ): Promise, Exception> { - val storage = MessagingModuleConfiguration.shared.storage - val queryParameters = mutableMapOf() - storage.getLastDeletionServerID(room, server)?.let { last -> - queryParameters["from_server_id"] = last.toString() - } - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.RoomDeleteMessages(room, storage.getUserPublicKey() ?: ""), - queryParameters = queryParameters - ) - return getResponseBody(request, signRequest = true).map { response -> - val json = JsonUtil.fromJson(response, Map::class.java) - val type = TypeFactory.defaultInstance() - .constructCollectionType(List::class.java, MessageDeletion::class.java) - val idsAsString = JsonUtil.toJson(json["ids"]) - val serverIDs = JsonUtil.fromJson>(idsAsString, type) - ?: throw Error.ParsingFailed - val lastMessageServerId = storage.getLastDeletionServerID(room, server) ?: 0 - val serverID = serverIDs.maxByOrNull { it.id } ?: MessageDeletion.empty - if (serverID.id > lastMessageServerId) { - storage.setLastDeletionServerID(room, server, serverID.id) - } - serverIDs - } - } // endregion // region Moderation - @JvmStatic - fun ban(publicKey: String, room: String, server: String): Promise { + suspend fun ban(publicKey: String, room: String, server: String) { val parameters = mapOf("rooms" to listOf(room)) val request = Request( verb = POST, @@ -608,12 +562,12 @@ object OpenGroupApi { endpoint = Endpoint.UserBan(publicKey), parameters = parameters ) - return send(request, signRequest = true).map { - Log.d("Loki", "Banned user: $publicKey from: $server.$room.") - } + + send(request, signRequest = true) + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") } - fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise { + suspend fun banAndDeleteAll(publicKey: String, room: String, server: String) { val requests = mutableListOf>( // Ban request @@ -633,25 +587,23 @@ object OpenGroupApi { responseType = object: TypeReference(){} ) ) - return sequentialBatch(server, requests).map { - Log.d("Loki", "Banned user: $publicKey from: $server.$room.") - } + sequentialBatch(server, requests) + Log.d("Loki", "Banned user: $publicKey from: $server.$room.") } - fun unban(publicKey: String, room: String, server: String): Promise { + suspend fun unban(publicKey: String, room: String, server: String) { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey)) - return send(request, signRequest = true).map { - Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") - } + send(request, signRequest = true) + Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") } // endregion // region General - fun parallelBatch( + suspend fun parallelBatch( server: String, requests: MutableList> - ): Promise>, Exception> { + ): List> { val request = Request( verb = POST, room = null, @@ -662,10 +614,10 @@ object OpenGroupApi { return getBatchResponseJson(request, requests) } - private fun sequentialBatch( + private suspend fun sequentialBatch( server: String, requests: MutableList> - ): Promise>, Exception> { + ): List> { val request = Request( verb = POST, room = null, @@ -676,97 +628,93 @@ object OpenGroupApi { return getBatchResponseJson(request, requests) } - private fun getBatchResponseJson( + private suspend fun getBatchResponseJson( request: Request, requests: MutableList>, signRequest: Boolean = true - ): Promise>, Exception> { - return getResponseBody(request, signRequest = signRequest).map { batch -> - val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed - results.mapIndexed { idx, result -> - val response = result as? Map<*, *> ?: throw Error.ParsingFailed - val code = response["code"] as Int - BatchResponse( - endpoint = requests[idx].endpoint, - code = code, - headers = response["headers"] as Map, - body = if (code in 200..299) { - requests[idx].responseType?.let { respType -> - JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let { - JsonUtil.fromJson(it, respType) - } ?: response["body"] - } - - } else null - ) - } + ): List> { + val batch = getResponseBody(request, signRequest = signRequest) + val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed + return results.mapIndexed { idx, result -> + val response = result as? Map<*, *> ?: throw Error.ParsingFailed + val code = response["code"] as Int + BatchResponse( + endpoint = requests[idx].endpoint, + code = code, + headers = response["headers"] as Map, + body = if (code in 200..299) { + requests[idx].responseType?.let { respType -> + JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let { + JsonUtil.fromJson(it, respType) + } ?: response["body"] + } + + } else null + ) } } - fun getDefaultServerCapabilities(): Promise, Exception> { - return GlobalScope.asyncPromise { getOrFetchServerCapabilities(defaultServer) } + suspend fun getDefaultServerCapabilities(): List { + return getOrFetchServerCapabilities(defaultServer) } - fun getDefaultRoomsIfNeeded(): Promise, Exception> { - return GlobalScope.asyncPromise { - val groups = getAllRooms().await() + suspend fun getDefaultRoomsIfNeeded(): List { + val groups = getAllRooms() - val earlyGroups = groups.map { group -> - DefaultGroup(group.token, group.name, null) - } - // See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results - defaultRooms.replayCache.firstOrNull()?.let { replayed -> - if (replayed.none { it.image?.isNotEmpty() == true }) { - defaultRooms.tryEmit(earlyGroups) - } - } - val images = groups.associate { group -> - group.token to group.imageId?.let { downloadOpenGroupProfilePicture( - server = defaultServer, - roomID = group.token, - imageId = it, - signRequest = false, - serverPubKeyHex = defaultServerPublicKey, - ) } + val earlyGroups = groups.map { group -> + DefaultGroup(group.token, group.name, null) + } + // See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results + defaultRooms.replayCache.firstOrNull()?.let { replayed -> + if (replayed.none { it.image?.isNotEmpty() == true }) { + defaultRooms.tryEmit(earlyGroups) } - groups.map { group -> - val image = try { - images[group.token]!!.await() - } catch (e: Exception) { - // No image or image failed to download - null - } - DefaultGroup(group.token, group.name, image) - }.also(defaultRooms::tryEmit) } + val images = groups.associate { group -> + group.token to group.imageId?.let { downloadOpenGroupProfilePicture( + server = defaultServer, + roomID = group.token, + imageId = it, + signRequest = false, + serverPubKeyHex = defaultServerPublicKey, + ) } + } + + return groups.map { group -> + val image = try { + images[group.token]!! + } catch (e: Exception) { + // No image or image failed to download + null + } + DefaultGroup(group.token, group.name, image) + }.also(defaultRooms::tryEmit) } - private fun getAllRooms(): Promise, Exception> { + private suspend fun getAllRooms(): List { val request = Request( verb = GET, room = null, server = defaultServer, endpoint = Endpoint.Rooms ) - return getResponseBody( + val response = getResponseBody( request = request, signRequest = false, serverPubKeyHex = defaultServerPublicKey - ).map { response -> - MessagingModuleConfiguration.shared.json - .decodeFromStream>(response.inputStream()) - .toList() - } + ) + + return MessagingModuleConfiguration.shared.json + .decodeFromStream>(response.inputStream()).toList() } - fun getCapabilities(server: String, serverPubKeyHex: String? = null): Promise { + suspend fun getCapabilities(server: String, serverPubKeyHex: String? = null): Capabilities { val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities) - return getResponseBody(request, signRequest = false, serverPubKeyHex).map { response -> - JsonUtil.fromJson(response, Capabilities::class.java) - } + val response = getResponseBody(request, signRequest = false, serverPubKeyHex) + return JsonUtil.fromJson(response, Capabilities::class.java) } - fun sendDirectMessage(message: String, blindedAccountId: String, server: String): Promise { + suspend fun sendDirectMessage(message: String, blindedAccountId: String, server: String): DirectMessage { val request = Request( verb = POST, room = null, @@ -774,21 +722,19 @@ object OpenGroupApi { endpoint = Endpoint.InboxFor(blindedAccountId), parameters = mapOf("message" to message) ) - return getResponseBody(request).map { response -> - JsonUtil.fromJson(response, DirectMessage::class.java) - } + val response = getResponseBody(request) + return JsonUtil.fromJson(response, DirectMessage::class.java) } - fun deleteAllInboxMessages(server: String): Promise, java.lang.Exception> { + suspend fun deleteAllInboxMessages(server: String): Map<*, *> { val request = Request( verb = DELETE, room = null, server = server, endpoint = Endpoint.Inbox ) - return getResponseBody(request).map { response -> - JsonUtil.fromJson(response, Map::class.java) - } + val response = getResponseBody(request) + return JsonUtil.fromJson(response, Map::class.java) } // endregion diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 8be647e12c..0b5158ce5f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -11,8 +11,6 @@ import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISI import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination @@ -32,7 +30,6 @@ import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.utilities.Address import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.crypto.PushTransportDetails @@ -46,6 +43,7 @@ import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.hasNamespaces import org.session.libsignal.utilities.hexEncodedPublicKey import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote @@ -72,13 +70,11 @@ object MessageSender { } // Convenience - fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean): Promise { + suspend fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean) { return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { - GlobalScope.asyncPromise { - sendToSnodeDestination(destination, message, isSyncMessage) - } + sendToSnodeDestination(destination, message, isSyncMessage) } } @@ -197,12 +193,7 @@ object MessageSender { // One-on-One Chats & Closed Groups private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) = supervisorScope { - val storage = MessagingModuleConfiguration.shared.storage val configFactory = MessagingModuleConfiguration.shared.configFactory - val userPublicKey = storage.getUserPublicKey() - - // recipient will be set later, so initialize it as a function here - val isSelfSend = { message.recipient == userPublicKey } // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { @@ -293,8 +284,7 @@ object MessageSender { } // Open Groups - private fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise { - val deferred = deferred() + private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { val storage = MessagingModuleConfiguration.shared.storage val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { @@ -343,11 +333,7 @@ object MessageSender { AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.pubKey.data).hexString } message.sender = messageSender - // Set the failure handler (need it here already for precondition failure handling) - fun handleFailure(error: Exception) { - handleFailedMessageSend(message, error) - deferred.reject(error) - } + try { // Attach the user's profile if needed if (message is VisibleMessage) { @@ -372,13 +358,19 @@ object MessageSender { sentTimestamp = message.sentTimestamp!!, base64EncodedData = Base64.encodeBytes(plaintext), ) - OpenGroupApi.sendMessage(openGroupMessage, destination.roomToken, destination.server, destination.whisperTo, destination.whisperMods, destination.fileIds).success { - message.openGroupServerMessageID = it.serverID - handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = it.sentTimestamp) - deferred.resolve(Unit) - }.fail { - handleFailure(it) - } + + val response = OpenGroupApi.sendMessage( + openGroupMessage, + destination.roomToken, + destination.server, + destination.whisperTo, + destination.whisperMods, + destination.fileIds + ) + + message.openGroupServerMessageID = response.serverID + handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = response.sentTimestamp) + return } is Destination.OpenGroupInbox -> { message.recipient = destination.blindedPublicKey @@ -394,20 +386,22 @@ object MessageSender { destination.serverPublicKey ) val base64EncodedData = Base64.encodeBytes(ciphertext) - OpenGroupApi.sendDirectMessage(base64EncodedData, destination.blindedPublicKey, destination.server).success { - message.openGroupServerMessageID = it.id - handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = TimeUnit.SECONDS.toMillis(it.postedAt)) - deferred.resolve(Unit) - }.fail { - handleFailure(it) - } + val response = OpenGroupApi.sendDirectMessage( + base64EncodedData, + destination.blindedPublicKey, + destination.server + ) + + message.openGroupServerMessageID = response.id + handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = TimeUnit.SECONDS.toMillis(response.postedAt)) + return } else -> throw IllegalStateException("Invalid destination.") } } catch (exception: Exception) { - handleFailure(exception) + if (exception !is CancellationException) handleFailedMessageSend(message, exception) + throw exception } - return deferred.promise } // Result Handling @@ -552,10 +546,10 @@ object MessageSender { resultChannel.receive().getOrThrow() } - fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise { + suspend fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean) { val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) - return sendNonDurably(message, destination, isSyncMessage) + sendNonDurably(message, destination, isSyncMessage) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 9c33087c91..3954404ac3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -71,7 +71,6 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager import java.security.SignatureException import javax.inject.Inject @@ -245,7 +244,7 @@ class ReceivedMessageHandler @Inject constructor( val timestamp = message.timestamp ?: return null val author = message.author ?: return null - val messageToDelete = storage.getMessageBy(timestamp, author) ?: return null + val messageToDelete = storage.getMessageByTimestamp(timestamp, author, false) ?: return null val messageIdToDelete = messageToDelete.messageId val messageType = messageToDelete.individualRecipient?.getType() @@ -327,7 +326,7 @@ class ReceivedMessageHandler @Inject constructor( Address.fromSerialized(quote.author) } - val messageInfo = messageDataProvider.getMessageForQuote(quote.id, author) + val messageInfo = messageDataProvider.getMessageForQuote(context.threadId, quote.id, author) quoteMessageBody = messageInfo?.third quoteModel = if (messageInfo != null) { val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() @@ -376,7 +375,7 @@ class ReceivedMessageHandler @Inject constructor( emoji = reaction.emoji!!, messageTimestamp = reaction.timestamp!!, threadId = context.threadId, - author = reaction.publicKey!!, + author = senderAddress.address, notifyUnread = threadIsGroup ) } @@ -770,17 +769,13 @@ fun constructReactionRecords( out: MutableMap> ) { if (reactions.isNullOrEmpty()) return - val communityAddress = context.threadAddress as? Address.Community ?: return + if (context.threadAddress !is Address.Community) return val messageId = context.messageDataProvider.getMessageID(openGroupMessageServerID, context.threadId) ?: return val outList = out.getOrPut(messageId) { arrayListOf() } for ((emoji, reaction) in reactions) { - val pendingUserReaction = OpenGroupApi.pendingReactions - .filter { it.server == communityAddress.serverUrl && it.room == communityAddress.room && it.messageId == openGroupMessageServerID && it.add } - .sortedByDescending { it.seqNo } - .any { it.emoji == emoji } - val shouldAddUserReaction = pendingUserReaction || reaction.you || reaction.reactors.contains(context.userPublicKey) + val shouldAddUserReaction = reaction.you || reaction.reactors.contains(context.userPublicKey) val reactorIds = reaction.reactors.filter { it != context.userBlindedKey && it != context.userPublicKey } val count = if (reaction.you) reaction.count - 1 else reaction.count // Add the first reaction (with the count) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt index 0573561c9f..3b09af6919 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt @@ -5,11 +5,8 @@ import android.content.Context interface MessageNotifier { fun setHomeScreenVisible(isVisible: Boolean) fun setVisibleThread(threadId: Long) - fun setLastDesktopActivityTimestamp(timestamp: Long) - fun cancelDelayedNotifications() fun updateNotification(context: Context) fun updateNotification(context: Context, threadId: Long) fun updateNotification(context: Context, threadId: Long, signal: Boolean) fun updateNotification(context: Context, signal: Boolean, reminderCount: Int) - fun clearReminder(context: Context) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 9411049169..a6c56bb47c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue @@ -37,7 +39,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi.parallelBatch import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -76,6 +77,7 @@ class OpenGroupPoller @AssistedInject constructor( private val communityDatabase: CommunityDatabase, @Assisted private val server: String, @Assisted private val scope: CoroutineScope, + @Assisted private val pollerSemaphore: Semaphore, ) { companion object { private const val POLL_INTERVAL_MILLS: Long = 4000L @@ -98,7 +100,11 @@ class OpenGroupPoller @AssistedInject constructor( Log.d(TAG, "Polling open group messages for server: $server") emit(PollState.Polling) - val pollResult = runCatching { pollOnce() } + val pollResult = runCatching { + pollerSemaphore.withPermit { + pollOnce() + } + } tokens.forEach { it.trySend(pollResult) } emit(PollState.Idle(pollResult)) @@ -283,7 +289,7 @@ class OpenGroupPoller @AssistedInject constructor( } ) } - return parallelBatch(server, requests).await() + return parallelBatch(server, requests) } @@ -295,7 +301,6 @@ class OpenGroupPoller @AssistedInject constructor( val sortedMessages = messages.sortedBy { it.seqno } sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> storage.setLastMessageServerID(roomToken, server, seqNo) - OpenGroupApi.pendingReactions.removeAll { !(it.seqNo == null || it.seqNo!! > seqNo) } } val (deletions, additions) = sortedMessages.partition { it.deleted } handleNewMessages(server, roomToken, additions.map { @@ -426,6 +431,6 @@ class OpenGroupPoller @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(server: String, scope: CoroutineScope): OpenGroupPoller + fun create(server: String, scope: CoroutineScope, pollerSemaphore: Semaphore): OpenGroupPoller } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt index 44deeb8891..463e3509f7 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType @@ -45,6 +46,8 @@ class OpenGroupPollerManager @Inject constructor( preferences: TextSecurePreferences, @ManagerScope scope: CoroutineScope ) : OnAppStartupComponent { + private val pollerSemaphore = Semaphore(3) + val pollers: StateFlow> = preferences.watchLocalNumber() .map { it != null } @@ -74,7 +77,7 @@ class OpenGroupPollerManager @Inject constructor( val scope = CoroutineScope(Dispatchers.Default) Log.d(TAG, "Creating new poller for $baseUrl") PollerHandle( - poller = pollerFactory.create(baseUrl, scope), + poller = pollerFactory.create(baseUrl, scope, pollerSemaphore), pollerScope = scope ) } diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 9315ed424d..116d5fbc2c 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -60,6 +60,8 @@ object OnionRequestAPI { .map { it.isNotEmpty() } .stateIn(GlobalScope, SharingStarted.Eagerly, paths.value.isNotEmpty()) + private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) + init { // Listen for the changes in paths and persist it to the db GlobalScope.launch { @@ -410,12 +412,20 @@ object OnionRequestAPI { } else { handleUnspecificError() } - } else if (destination is Destination.Server && exception.statusCode == 400) { - Log.d("Loki","Destination server returned code ${exception.statusCode} with message: $message") + } else if(exception.statusCode in NON_PENALIZING_STATUSES){ + // error codes that shouldn't penalize our path or drop snodes + // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here + Log.d("Loki","Request returned a non penalizing code ${exception.statusCode} with message: $message") + } + // we do not want to penalize the path/nodes when: + // - the exit node reached the server but the destination returned 5xx or 400 + // - the exit node couldn't reach its destination with a 5xx or 400, but the destination was a community (which we can know from the server's name being in the error message) + else if (destination is Destination.Server && + (exception.statusCode in 500..504 || exception.statusCode == 400) && + (exception is HTTPRequestFailedAtDestinationException || exception.body?.contains(destination.host) == true)) { + Log.d("Loki","Destination server error - Non path penalizing. Request returned code ${exception.statusCode} with message: $message") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") - } else if (exception.statusCode == 404) { - // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here } else { // Only drop snode/path if not receiving above two exception cases handleUnspecificError() } diff --git a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt index 6188ea17b6..fa906fc259 100644 --- a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt +++ b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt @@ -22,10 +22,6 @@ class OwnedSwarmAuth( } } - override fun signForPushRegistry(data: ByteArray): Map { - return sign(data) - } - companion object { fun ofClosedGroup(groupAccountId: AccountId, adminKey: ByteArray): OwnedSwarmAuth { return OwnedSwarmAuth( diff --git a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt index a96846f1b2..738a0ef8cd 100644 --- a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt +++ b/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt @@ -12,12 +12,6 @@ interface SwarmAuth { */ fun sign(data: ByteArray): Map - /** - * Sign the given data and return the signature JSON structure. - * This variant is used for push registry requests. - */ - fun signForPushRegistry(data: ByteArray): Map - val accountId: AccountId val ed25519PublicKeyHex: String? } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt b/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt deleted file mode 100644 index f47bd67baa..0000000000 --- a/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.session.libsession.utilities - -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.Log - -object DownloadUtilities { - /** - * Downloads a file from the file server using the provided URL. - * - * This will assume the URL is a valid file server URL, and if not, - */ - suspend fun downloadFromFileServer(urlAsString: String): FileServerApi.SendResponse { - try { - val url = urlAsString.toHttpUrl() - require(url.host == FileServerApi.fileServerUrl.host) { - "Invalid file server URL: $url" - } - val fileID = checkNotNull(url.pathSegments.lastOrNull()) { - "No file ID found in URL: $url" - } - - return FileServerApi.download(fileID).await() - } catch (e: Exception) { - when (e) { - // No need for the stack trace for HTTP errors - is HTTP.HTTPRequestFailedException -> Log.e("Loki", "Couldn't download attachment due to error: ${e.message}") - else -> Log.e("Loki", "Couldn't download attachment", e) - } - throw e - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 811771e60d..c6b348e2c6 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -17,9 +17,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import kotlinx.serialization.json.Json import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.file_server.FileServer import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK @@ -226,6 +228,9 @@ interface TextSecurePreferences { var selectedActivityAliasName: String? var inAppReviewState: String? + var forcesDeterministicAttachmentEncryption: Boolean + var debugAvatarReupload: Boolean + var alternativeFileServer: FileServer? companion object { @@ -371,6 +376,7 @@ interface TextSecurePreferences { const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" + const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" @JvmStatic fun getConfigurationMessageSynced(context: Context): Boolean { @@ -961,7 +967,8 @@ interface TextSecurePreferences { @Singleton class AppTextSecurePreferences @Inject constructor( - @ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context, + private val json: Json, ): TextSecurePreferences { private val localNumberState = MutableStateFlow(getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null)) private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, false)) @@ -1651,7 +1658,7 @@ class AppTextSecurePreferences @Inject constructor( } /** - * Clear all prefs and reset or observables + * Clear all prefs and reset our observables */ override fun clearAll() { pushEnabled.update { false } @@ -1749,4 +1756,28 @@ class AppTextSecurePreferences @Inject constructor( override fun setSubscriptionProvider(provider: String) { setStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, provider) } + + override var forcesDeterministicAttachmentEncryption: Boolean + get() = getBooleanPreference("forces_deterministic_attachment_upload", false) + set(value) { + setBooleanPreference("forces_deterministic_attachment_upload", value) + } + + override var debugAvatarReupload: Boolean + get() = getBooleanPreference(TextSecurePreferences.DEBUG_AVATAR_REUPLOAD, false) + set(value) { + setBooleanPreference(TextSecurePreferences.DEBUG_AVATAR_REUPLOAD, value) + _events.tryEmit(TextSecurePreferences.DEBUG_AVATAR_REUPLOAD) + } + + override var alternativeFileServer: FileServer? + get() = getStringPreference("alternative_file_server", null)?.let { + json.decodeFromString(it) + } + + set(value) { + setStringPreference("alternative_file_server", value?.let { + json.encodeToString(it) + }) + } } diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt new file mode 100644 index 0000000000..ec2b0181b9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/HttpSerializer.kt @@ -0,0 +1,28 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +class HttpSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = HttpUrl::javaClass.name, + kind = PrimitiveKind.STRING + ) + + override fun serialize( + encoder: Encoder, + value: HttpUrl + ) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): HttpUrl { + return decoder.decodeString().toHttpUrl() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java b/app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java index fd3c8123df..37aaea75b7 100644 --- a/app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java +++ b/app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java @@ -10,6 +10,7 @@ import org.session.libsignal.exceptions.InvalidMacException; import org.session.libsignal.exceptions.InvalidMessageException; +import org.session.libsignal.utilities.ByteArraySlice; import org.session.libsignal.utilities.Util; import java.io.File; @@ -66,8 +67,9 @@ public static InputStream createForAttachment(File file, long plaintextLength, b throw new InvalidMacException("Missing digest!"); } - FileInputStream fin = new FileInputStream(file); - verifyMac(fin, file.length(), mac, digest); + try (FileInputStream fin = new FileInputStream(file)) { + verifyMac(fin, file.length(), mac, digest); + } InputStream inputStream = new AttachmentCipherInputStream(new FileInputStream(file), parts[0], file.length() - BLOCK_SIZE - mac.getMacLength()); @@ -75,7 +77,40 @@ public static InputStream createForAttachment(File file, long plaintextLength, b inputStream = new ContentLengthInputStream(inputStream, plaintextLength); } - return inputStream; + return new AttachmentCipherInputStream(inputStream, parts[0], file.length() - BLOCK_SIZE - mac.getMacLength()); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } catch (InvalidKeyException e) { + throw new AssertionError(e); + } catch (InvalidMacException e) { + throw new InvalidMessageException(e); + } + } + + public static InputStream createForAttachment(ByteArraySlice ciphertext, byte[] combinedKeyMaterial, byte[] digest) + throws InvalidMessageException, IOException + { + try { + byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(parts[1], "HmacSHA256")); + + if (ciphertext.getLen() <= BLOCK_SIZE + mac.getMacLength()) { + throw new InvalidMessageException("Message shorter than crypto overhead!"); + } + + if (digest == null) { + throw new InvalidMacException("Missing digest!"); + } + + try (final InputStream input = ciphertext.inputStream()) { + verifyMac(input, ciphertext.getLen(), mac, digest); + } + + return new AttachmentCipherInputStream(ciphertext.inputStream(), + parts[0], + ciphertext.getLen() - BLOCK_SIZE - mac.getMacLength() + ); } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); } catch (InvalidKeyException e) { @@ -107,6 +142,13 @@ private AttachmentCipherInputStream(InputStream inputStream, byte[] cipherKey, l } } + @Override + public void close() throws IOException { + super.close(); + + in.close(); + } + @Override public int read(byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); diff --git a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java deleted file mode 100644 index 43734605ae..0000000000 --- a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.session.libsignal.streams; - -import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; -import static org.session.libsignal.utilities.Util.SECURE_RANDOM; - -import java.io.IOException; -import java.io.OutputStream; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -public class ProfileCipherOutputStream extends DigestingOutputStream { - - private final Cipher cipher; - - public ProfileCipherOutputStream(OutputStream out, byte[] key) throws IOException { - super(out); - try { - this.cipher = Cipher.getInstance("AES/GCM/NoPadding"); - - byte[] nonce = generateNonce(); - this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); - - super.write(nonce, 0, nonce.length); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (NoSuchPaddingException e) { - throw new AssertionError(e); - } catch (InvalidAlgorithmParameterException e) { - throw new AssertionError(e); - } catch (InvalidKeyException e) { - throw new IOException(e); - } - } - - @Override - public void write(byte[] buffer) throws IOException { - write(buffer, 0, buffer.length); - } - - @Override - public void write(byte[] buffer, int offset, int length) throws IOException { - byte[] output = cipher.update(buffer, offset, length); - super.write(output); - } - - @Override - public void write(int b) throws IOException { - byte[] input = new byte[1]; - input[0] = (byte)b; - - byte[] output; - synchronized (CIPHER_LOCK) { - output = cipher.update(input); - } - super.write(output); - } - - @Override - public void flush() throws IOException { - try { - byte[] output; - synchronized (CIPHER_LOCK) { - output = cipher.doFinal(); - } - - super.write(output); - super.flush(); - } catch (BadPaddingException | IllegalBlockSizeException e) { - throw new AssertionError(e); - } - } - - private byte[] generateNonce() { - byte[] nonce = new byte[12]; - SECURE_RANDOM.nextBytes(nonce); - return nonce; - } - - public static long getCiphertextLength(long plaintextLength) { - return 12 + 16 + plaintextLength; - } -} diff --git a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java deleted file mode 100644 index d29be29602..0000000000 --- a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.session.libsignal.streams; - -import java.io.IOException; -import java.io.OutputStream; - -public class ProfileCipherOutputStreamFactory implements OutputStreamFactory { - - private final byte[] key; - - public ProfileCipherOutputStreamFactory(byte[] key) { - this.key = key; - } - - @Override - public DigestingOutputStream createFor(OutputStream wrap) throws IOException { - return new ProfileCipherOutputStream(wrap, key); - } - -} diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt index fc29d07916..2ffd693c14 100644 --- a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -2,6 +2,8 @@ package org.session.libsignal.utilities import android.util.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType @@ -73,7 +75,8 @@ object HTTP { open class HTTPRequestFailedException( val statusCode: Int, - val json: Map<*, *>?, + val json: Map<*, *>? = null, + val body: String? = null, message: String = "HTTP request failed with status code $statusCode" ) : kotlin.Exception(message) class HTTPNoNetworkException : HTTPRequestFailedException(0, null, "No network connection") @@ -131,10 +134,10 @@ object HTTP { else -> defaultConnection }.newCall(request.build()).await().use { response -> when (val statusCode = response.code) { - 200 -> response.body!!.bytes() + in 200..299 -> response.body.bytes() else -> { Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.") - throw HTTPRequestFailedException(statusCode, null) + throw HTTPRequestFailedException(statusCode, body = response.body.string()) } } } @@ -143,17 +146,26 @@ object HTTP { if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } - // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI - throw HTTPRequestFailedException(0, null, "HTTP request failed due to: ${exception.message}") + if (exception !is HTTPRequestFailedException) { + + // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI + throw HTTPRequestFailedException( + statusCode = 0, + message = "HTTP request failed due to: ${exception.message}" + ) + } else { + throw exception + } } } - @Suppress("OPT_IN_USAGE") - private val httpCallDispatcher = Dispatchers.IO.limitedParallelism(15) + private val httpCallSemaphore = Semaphore(20) private suspend fun Call.await(): Response { - return withContext(httpCallDispatcher) { - execute() + return httpCallSemaphore.withPermit { + withContext(Dispatchers.IO) { + execute() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 62930f4f8d..1ebbedd2bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -19,8 +19,6 @@ import android.app.Application import android.content.Context import android.content.Intent import android.os.AsyncTask -import android.os.Handler -import android.os.HandlerThread import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -32,7 +30,6 @@ import androidx.work.Configuration import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader -import coil3.request.crossfade import dagger.Lazy import dagger.hilt.EntryPoints import dagger.hilt.android.HiltAndroidApp @@ -53,7 +50,6 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuff import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.AppContext.configureKovenant -import org.thoughtcrime.securesms.coil.RemoteFileKeyer import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseModule.init diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index bfd4e8a996..93d5841385 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -143,7 +143,6 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), private var albumRailAdapter: MediaRailAdapter? = null private var windowInsetBottom = 0 - private var railHeight = 0 @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate(bundle: Bundle?, ready: Boolean) { @@ -172,7 +171,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), windowInsetBottom = insets.bottom binding.toolbar.updatePadding(top = insets.top) - binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = max(insets.bottom, binding.mediaPreviewAlbumRailContainer.paddingBottom)) + binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = insets.bottom) updateControlsPosition() @@ -213,12 +212,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), * Updates the media controls' position based on the rail's position */ private fun updateControlsPosition() { - // the ypos of the controls is either the window bottom inset, or the rail height if there is a rail - // since the rail height takes the window inset into account with its padding - val totalBottomPadding = max( - windowInsetBottom, - railHeight + resources.getDimensionPixelSize(R.dimen.medium_spacing) - ) + val totalBottomPadding = windowInsetBottom + + binding.mediaPreviewAlbumRail.height+ + resources.getDimensionPixelSize(R.dimen.medium_spacing) adapter?.setControlsYPosition(totalBottomPadding) } @@ -431,7 +427,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), binding.mediaPreviewAlbumRailContainer.viewTreeObserver.removeOnGlobalLayoutListener( this ) - railHeight = binding.mediaPreviewAlbumRailContainer.height + updateControlsPosition() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt index 0fa83bf402..e65ced674c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.groups.compose.MemberItem import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -66,7 +67,7 @@ fun ShareScreen( fun ShareList( state: ShareViewModel.UIState, contacts: List, - hasConversations: Boolean, + hasConversations: Boolean?, onContactItemClicked: (address: Address) -> Unit, searchQuery: String, onSearchQueryChanged: (String) -> Unit, @@ -116,29 +117,41 @@ fun ShareList( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - if (!hasConversations) { - Text( - text = stringResource(id = R.string.conversationsNone), - modifier = Modifier.padding(top = LocalDimensions.current.spacing) - .align(Alignment.CenterHorizontally), - style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) - ) - } else { - LazyColumn( - state = scrollState, - contentPadding = PaddingValues(bottom = paddings.calculateBottomPadding()), - ) { - - items(contacts) { contacts -> - // Each member's view - MemberItem( - address = contacts.address, - onClick = onContactItemClicked, - title = contacts.name, - showProBadge = contacts.showProBadge, - showAsAdmin = false, - avatarUIData = contacts.avatarUIData - ) + when(hasConversations){ + // null means we don't yet have a result, so show a loader + null -> { + SmallCircularProgressIndicator( + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.CenterHorizontally), + ) + } + + false -> { + Text( + text = stringResource(id = R.string.conversationsNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.CenterHorizontally), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } + + true -> { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = paddings.calculateBottomPadding()), + ) { + + items(contacts) { contacts -> + // Each member's view + MemberItem( + address = contacts.address, + onClick = onContactItemClicked, + title = contacts.name, + showProBadge = contacts.showProBadge, + showAsAdmin = false, + avatarUIData = contacts.avatarUIData + ) + } } } } @@ -201,6 +214,25 @@ private fun PreviewSelectEmptyContacts() { } } +@Preview +@Composable +private fun PreviewSelectFetchingContacts() { + val contacts = emptyList() + + PreviewTheme { + ShareList( + state = ShareViewModel.UIState(false), + contacts = contacts, + hasConversations = null, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onBack = {}, + ) + } +} + @Preview @Composable private fun PreviewSelectEmptyContactsWithSearch() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt index df259eed83..4b3b3333a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt @@ -71,10 +71,10 @@ class ShareViewModel @Inject constructor( ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - val hasAnyConversations: StateFlow = + val hasAnyConversations: StateFlow = conversationRepository.observeConversationList() .map { it.isNotEmpty() } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) val uiEvents: SharedFlow get() = _uiEvents diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt new file mode 100644 index 0000000000..668249f8ad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt @@ -0,0 +1,518 @@ +package org.thoughtcrime.securesms.attachments + +import android.content.Context +import android.graphics.Bitmap +import android.text.format.Formatter +import androidx.compose.ui.unit.IntSize +import coil3.BitmapImage +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.CachePolicy +import coil3.request.ImageRequest.Builder +import coil3.request.Options +import coil3.request.allowConversionToBitmap +import coil3.request.allowHardware +import coil3.request.allowRgb565 +import coil3.size.Precision +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import network.loki.messenger.libsession_util.encrypt.Attachments +import network.loki.messenger.libsession_util.image.GifUtils +import network.loki.messenger.libsession_util.image.WebPUtils +import okio.BufferedSource +import okio.FileSystem +import okio.buffer +import okio.source +import org.session.libsession.utilities.Util +import org.session.libsignal.streams.AttachmentCipherInputStream +import org.session.libsignal.streams.AttachmentCipherOutputStream +import org.session.libsignal.streams.PaddingInputStream +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.util.AnimatedImageUtils +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.ImageUtils +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.security.MessageDigest +import java.util.concurrent.TimeoutException +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.math.roundToInt + +typealias DigestResult = ByteArray + +/** + * A central class to handle attachment resizing/exif stripping/compression/encryption, etc. + */ +@Singleton +class AttachmentProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val imageLoader: Provider, + private val storage: Lazy, +) { + class ProcessResult( + val data: ByteArray, + val mimeType: String, + val imageSize: IntSize + ) + + suspend fun processAvatar( + data: ByteArray, + ): ProcessResult? { + val buffer = ByteArrayInputStream(data).source().buffer() + return when { + AnimatedImageUtils.isAnimatedWebP(buffer) -> { + val convertResult = runCatching { + buffer.peek().use { + processAnimatedWebP( + data = it, + maxImageResolution = MAX_AVATAR_SIZE_PX, + timeoutMills = 5_000L, + ) + } + } + + val processResult = when { + convertResult.isSuccess -> convertResult.getOrThrow() ?: return null + convertResult.exceptionOrNull() is TimeoutException -> { + Log.w(TAG, "Animated WebP processing timed out, skipping") + return null + } + + else -> throw convertResult.exceptionOrNull()!! + } + + if (processResult.data.size > data.size) { + Log.d( + TAG, + "Avatar processing increased size from ${data.size} to ${processResult.data.size}, skipped result" + ) + return null + } else { + processResult + } + } + + AnimatedImageUtils.isAnimatedGif(data) -> { + val origSize = ByteArrayInputStream(data).use(BitmapUtil::getDimensions) + .let { pair -> IntSize(pair.first, pair.second) } + + val targetSize = if (origSize.width <= MAX_AVATAR_SIZE_PX.width && + origSize.height <= MAX_AVATAR_SIZE_PX.height) { + origSize + } else { + scaleToFit(origSize, MAX_AVATAR_SIZE_PX).first + } + + // First try to convert to webp in 5 seconds + val convertResult = runCatching { + "image/webp" to WebPUtils.encodeGifToWebP( + input = data, + timeoutMills = 5_000L, + targetWidth = targetSize.width, targetHeight = targetSize.height + ) + }.recoverCatching { e -> + if (e is TimeoutException) { + // If we timed out, try re-encoding as GIF in 2 seconds + Log.w(TAG, "WebP conversion timed out, trying GIF re-encoding as fallback") + "image/gif" to GifUtils.reencodeGif( + input = data, + timeoutMills = 2_000L, + targetWidth = targetSize.width, + targetHeight = targetSize.height + ) + } else { + throw e + } + } + + val processResult = when { + convertResult.isSuccess -> { + val (mimeType, result) = convertResult.getOrThrow() + ProcessResult( + data = result, + mimeType = mimeType, + imageSize = targetSize + ) + } + + convertResult.exceptionOrNull() is TimeoutException -> { + Log.w(TAG, "All operation times out, skipping avatar processing") + null + } + + else -> { + throw convertResult.exceptionOrNull()!! + } + } + + if (processResult != null && processResult.data.size > data.size) { + Log.d(TAG, "Avatar processing increased size from ${data.size} to ${processResult.data.size}, skipped result") + return null + } + + processResult + } + + else -> { + // All static images + val (data, size) = processStaticImage(data, MAX_AVATAR_SIZE_PX, Bitmap.CompressFormat.WEBP, 90) + ProcessResult( + data = data, + mimeType = "image/webp", + imageSize = size + ) + } + } + } + + /** + * Process a file based on its mime type and the given constraints. + * + * @param data The data to process. This method will **NOT** close the source for you. If + * this function returns null, the source will remain unconsumed. + * + * @return null if nothing was done, or a ProcessResult if processing was performed. + */ + suspend fun process( + data: BufferedSource, + maxImageResolution: IntSize?, + compressImage: Boolean, + ): ProcessResult? { + when { + ImageUtils.isJpeg(data) -> { + val (data, imageSize) = processStaticImage( + data = data, + maxImageResolution = maxImageResolution, + format = Bitmap.CompressFormat.JPEG, + quality = if (compressImage) 75 else 95, + ) + + return ProcessResult( + data = data, + imageSize = imageSize, + mimeType = "image/jpeg", + ) + } + + AnimatedImageUtils.isAnimatedWebP(data) -> { + if (maxImageResolution == null) { + Log.d(TAG, "Skipping processing of animated WebP with no size constraints") + return null + } + + return processAnimatedWebP( + data = data, + maxImageResolution = maxImageResolution, + timeoutMills = 30_000L + ) + } + + ImageUtils.isWebP(data) -> { + val (data, imageSize) = processStaticImage( + data = data, + maxImageResolution = maxImageResolution, + format = Bitmap.CompressFormat.WEBP, + quality = if (compressImage) 75 else 95, + ) + + return ProcessResult( + data = data, + imageSize = imageSize, + mimeType = "image/webp", + ) + } + + ImageUtils.isGif(data) -> { + if (maxImageResolution == null) { + Log.d(TAG, "Skipping processing of GIF with no size constraints") + return null + } + + return processGif(data, maxImageResolution) + } + + ImageUtils.isPng(data) -> { + val (data, imageSize) = processStaticImage( + data = data, + maxImageResolution = maxImageResolution, + format = if (android.os.Build.VERSION.SDK_INT >= 30) + Bitmap.CompressFormat.WEBP_LOSSLESS + else + Bitmap.CompressFormat.WEBP, + quality = if (compressImage) 75 else 95, + ) + + return ProcessResult( + data = data, + imageSize = imageSize, + mimeType = "image/webp", + ) + } + + else -> return null // No processing needed + } + } + + + class EncryptResult( + val ciphertext: ByteArray, + val key: ByteArray, + ) + + /** + * Encrypt the given data using deterministic encryption from libsession. + */ + fun encryptDeterministically(plaintext: ByteArray, domain: Attachments.Domain): EncryptResult { + val cipherOut = ByteArray(Attachments.encryptedSize(plaintext.size.toLong()).toInt()) + val privateKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey) { + "No user identity available" + } + check(privateKey.data.size == 64) { + "Invalid ED25519 private key size: ${privateKey.data.size}" + } + val seed = privateKey.data.sliceArray(0 until 32) + + val key = Attachments.encryptBytes( + seed = seed, + plaintextIn = plaintext, + cipherOut = cipherOut, + domain = domain, + ) + + return EncryptResult( + ciphertext = cipherOut, + key = key, + ) + } + + /** + * Encrypt the given data using the legacy attachment encryption method, returning a digest + * for the encrypted data too. + */ + fun encryptAttachmentLegacy(plaintext: ByteArray): Pair { + val key = Util.getSecretBytes(64) + var remainingPaddingSize = + (PaddingInputStream.getPaddedSize(plaintext.size.toLong()) - plaintext.size.toLong()).toInt() + val paddingBuffer = ByteArray(remainingPaddingSize.coerceAtMost(512)) + val digest: ByteArray + + val cipherText = ByteArrayOutputStream().also { outputStream -> + AttachmentCipherOutputStream(key, outputStream).use { os -> + os.write(plaintext) + + while (remainingPaddingSize > 0) { + val toWrite = remainingPaddingSize.coerceAtMost(paddingBuffer.size) + os.write(paddingBuffer, 0, toWrite) + remainingPaddingSize -= toWrite + } + + os.flush() + + digest = os.transmittedDigest + } + }.toByteArray() + + return EncryptResult( + ciphertext = cipherText, + key = key, + ) to digest + } + + fun decryptDeterministically(ciphertext: ByteArraySlice, key: ByteArray): ByteArraySlice { + val plaintextOut = ByteArray( + requireNotNull(Attachments.decryptedMaxSizeOrNull(ciphertext.len.toLong())) { + "Ciphertext size ${ciphertext.len} is too small to be valid" + }.toInt() + ) + + val plaintextSize = Attachments.decryptBytes( + key = key, + cipherIn = ciphertext.data, + cipherInOffset = ciphertext.offset, + cipherInLen = ciphertext.len, + plainOut = plaintextOut, + plainOutOffset = 0, + plainOutLen = plaintextOut.size, + ).toInt() + + return plaintextOut.view(0 until plaintextSize) + } + + fun decryptAttachmentLegacy( + ciphertext: ByteArraySlice, + key: ByteArray, + digest: ByteArray? + ): ByteArraySlice { + return AttachmentCipherInputStream.createForAttachment(ciphertext, key, digest) + .use { it.readBytes().view() } + } + + fun digest(data: ByteArray): ByteArray { + try { + return MessageDigest.getInstance("SHA256").digest(data) + } catch (e: Exception) { + Log.e(TAG, "Failed to compute SHA256 digest", e) + return byteArrayOf() + } + } + + private fun scaleToFit(original: IntSize, max: IntSize): Pair { + val scale = minOf( + max.width.toDouble() / original.width.toDouble(), + max.height.toDouble() / original.height.toDouble() + ) + + return IntSize( + width = (original.width * scale).roundToInt(), + height = (original.height * scale).roundToInt() + ) to scale.toFloat() + } + + private fun processAnimatedWebP( + data: BufferedSource, + maxImageResolution: IntSize, + timeoutMills: Long, + ): ProcessResult? { + val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions) + .let { pair -> IntSize(pair.first, pair.second) } + + val targetSize: IntSize + + if (origSize.width <= maxImageResolution.width && origSize.height <= maxImageResolution.height) { + // No resizing needed hence no processing + return null + } else { + targetSize = scaleToFit( + original = origSize, + max = maxImageResolution + ).first + } + + val start = System.currentTimeMillis() + + val reencoded = WebPUtils.reencodeWebPAnimation( + input = data.readByteArray(), + targetWidth = targetSize.width, + targetHeight = targetSize.height, + timeoutMills = timeoutMills, + ) + + Log.d( + TAG, + "Re-encoded animated WebP from ${origSize.width}x${origSize.height} to ${targetSize.width}x${targetSize.height} " + + "in ${System.currentTimeMillis() - start}ms, " + + "new size=${Formatter.formatFileSize(context, reencoded.size.toLong())}" + ) + + return ProcessResult( + data = reencoded, + mimeType = "image/webp", + imageSize = targetSize + ) + } + + private suspend fun processStaticImage( + data: Any, + maxImageResolution: IntSize?, + format: Bitmap.CompressFormat, + quality: Int, + ): Pair { + val builder = Builder(context) + .allowHardware(false) + .allowRgb565(true) + .allowConversionToBitmap(true) + .memoryCachePolicy(CachePolicy.DISABLED) + .networkCachePolicy(CachePolicy.DISABLED) + .fetcherFactory(BufferedSourceFetcherFactory()) + + if (maxImageResolution != null) { + builder.size(maxImageResolution.width, maxImageResolution.height) + .precision(Precision.INEXACT) + } + + val result = imageLoader.get().execute(builder.data(data).build()) + val bitmap = checkNotNull(result.image as? BitmapImage) { + "Expected a BitmapImage but got ${result.image?.javaClass}" + }.bitmap + + return ByteArrayOutputStream().also { out -> + bitmap.compress(format, quality, out) + }.toByteArray() to IntSize(bitmap.width, bitmap.height) + } + + @Suppress("DEPRECATION") + private fun processGif( + data: BufferedSource, + maxImageResolution: IntSize? + ): ProcessResult? { + val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions) + .let { pair -> IntSize(pair.first, pair.second) } + + val targetSize: IntSize + + if (maxImageResolution == null || ( + origSize.width <= maxImageResolution.width && + origSize.height <= maxImageResolution.height) + ) { + // No resizing needed hence no processing + return null + } else { + targetSize = scaleToFit( + original = origSize, + max = maxImageResolution + ).first + } + + val reencoded = GifUtils.reencodeGif( + input = data.readByteArray(), + targetWidth = targetSize.width, + targetHeight = targetSize.height, + timeoutMills = 10_000L, + ) + + Log.d( + TAG, + "Re-encoded animated gif from ${origSize.width}x${origSize.height} to ${targetSize.width}x${targetSize.height} " + + "new size=${Formatter.formatFileSize(context, reencoded.size.toLong())}" + ) + + return ProcessResult( + data = reencoded, + mimeType = "image/gif", + imageSize = targetSize + ) + } + + /** + * A locally scoped fetcher to allow us to use Coil's for decoding an inputStream. + */ + private class BufferedSourceFetcherFactory : Fetcher.Factory { + override fun create( + data: BufferedSource, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return Fetcher { + SourceFetchResult( + source = ImageSource(data, FileSystem.SYSTEM), + mimeType = null, + dataSource = DataSource.MEMORY + ) + } + } + } + + companion object { + private const val TAG = "AttachmentProcessor" + + val MAX_AVATAR_SIZE_PX = IntSize(600, 600) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt new file mode 100644 index 0000000000..e013b5f259 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt @@ -0,0 +1,308 @@ +package org.thoughtcrime.securesms.attachments + +import android.content.Context +import androidx.annotation.CheckResult +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.AESGCM +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.RemoteFile +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.ByteArraySlice.Companion.write +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.util.DateUtils.Companion.millsToInstant +import org.thoughtcrime.securesms.util.getRootCause +import java.io.File +import java.io.InputStream +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A manager that handles downloading and caching user avatars. + */ +@Singleton +class AvatarDownloadManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, + private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, + private val prefs: TextSecurePreferences, + private val recipientSettingsDatabase: RecipientSettingsDatabase, + private val configFactory: ConfigFactoryProtocol, + private val fileServerApi: FileServerApi, + private val attachmentProcessor: AttachmentProcessor, +) { + /** + * A map of mutexes to synchronize downloads for each remote file. + */ + private val downloadMutex = ConcurrentHashMap() + + // Return null if the file doesn't exist locally or corrupted + private fun openDownloadedFile(file: File): InputStream? { + if (!file.exists()) return null + + val downloadedFile = runCatching { + localEncryptedFileInputStreamFactory.create(file) + }.onFailure { e -> + Log.w(TAG, "Failed to read downloaded file $file", e) + // If we can't read the file, we assume it's corrupted and we need to delete it. + if (!file.delete()) { + Log.w(TAG, "Failed to delete corrupted file $file") + } + }.getOrNull() + + when { + downloadedFile?.meta?.hasPermanentDownloadError == true -> { + throw NonRetryableException("Previous download failed permanently for file $file") + } + + downloadedFile != null -> { + Log.d(TAG, "Avatar file already downloaded: $file") + return downloadedFile + } + + else -> return null + } + } + + + /** + * Downloads the given remote file, returning an InputStream to read the downloaded file. + * If the file has already been downloaded, returns an InputStream to read the cached file. + * + * @throws NonRetryableException if the download failed permanently. + * @return InputStream to read the downloaded file. It's the caller's responsibility to close the stream. + */ + @CheckResult + suspend fun download(file: RemoteFile): InputStream { + val downloaded = computeFileName(context, file) + + // Quickly look at the downloaded file without holding the lock, + // in case we already have it downloaded. + openDownloadedFile(downloaded)?.let { return it } + + // Now given we don't have a locally download file, we will hold the mutex for this + // file when we try to download it from network + downloadMutex.getOrPut(file) { Mutex() }.withLock { + // Once we hold the lock, we MUST check the local file again just in case + // another coroutine already downloaded it while we were waiting for the lock + openDownloadedFile(downloaded)?.let { return it } + + Log.d(TAG, "Start downloading file from $file") + + val (bytes, meta) = try { + downloadAndDecryptFile(file) + } catch (e: Exception) { + if (e.getRootCause() != null || + e.getRootCause()?.statusCode == 404 + ) { + Log.w(TAG, "Download failed permanently for file $file", e) + // Write an empty file with a permanent error metadata if the download failed permanently. + localEncryptedFileOutputStreamFactory.create( + downloaded, FileMetadata( + hasPermanentDownloadError = true + ) + ).use {} + + throw NonRetryableException("Download failed permanently for file $file", e) + } else { + throw e + } + } + + + // A temp file to clear, if it exists + var tmpFileToClean: File? = null + try { + // Re-encrypt the file with our streaming cipher, and encode it with the metadata, + // and doing it to a temporary file first. + downloaded.parentFile!!.mkdirs() + val tmpFile = File.createTempFile("downloaded-", null, downloaded.parentFile) + .also { tmpFileToClean = it } + + localEncryptedFileOutputStreamFactory.create(tmpFile, meta) + .use { fos -> fos.write(bytes) } + + // Once done, rename the temporary file to the final file name. + check(tmpFile.renameTo(downloaded)) { + "Failed to rename temporary file ${tmpFile.absolutePath} to $downloaded" + } + + // Since we successfully moved the file, we don't need to delete the temporary file anymore. + tmpFileToClean = null + Log.d(TAG, "Successfully downloaded file $file") + return localEncryptedFileInputStreamFactory.create(downloaded) + } catch (e: CancellationException) { + Log.i(TAG, "Download cancelled for file $file") + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to download file $file", e) + throw e + } finally { + tmpFileToClean?.delete() + } + } + } + + private fun findRecipientsForProfilePic(profilePicUrl: String): Set
{ + return buildSet { + addAll(recipientSettingsDatabase.findRecipientsForProfilePic(profilePicUrl)) + + val allGroups = configFactory.withUserConfigs { configs -> + if (configs.userProfile.getPic().url == profilePicUrl) { + // If the profile picture URL matches the one in the user config, add the local number + // as a recipient as well. + add(Address.fromSerialized(prefs.getLocalNumber()!!)) + } + + // Search through all contacts to find any that have this profile picture URL. + configs.contacts.all() + .asSequence() + .filter { it.profilePicture.url == profilePicUrl } + .mapTo(this) { it.id.toAddress() } + + // Search through all blinded contacts to find any that have this profile picture URL. + configs.contacts.allBlinded() + .asSequence() + .filter { it.profilePic.url == profilePicUrl } + .mapTo(this) { it.id.toAddress() } + + // Return the group addresses for further processing. + configs.userGroups.allClosedGroupInfo() + } + + for (group in allGroups) { + configFactory.withGroupConfigs(AccountId(group.groupAccountId)) { configs -> + configs.groupMembers.all() + .asSequence() + .filter { it.profilePic()?.url == profilePicUrl } + .mapTo(this) { it.accountId().toAddress() } + } + } + } + } + + private suspend fun downloadAndDecryptFile(file: RemoteFile): Pair { + return when (file) { + is RemoteFile.Encrypted -> { + // Look at the db and find out which addresses have this file as their profile picture, + // then we will look at the old location of the avatar file and migrate it to the new location. + // Once the migration grace period is over, this copying code shall be removed. + for (address in findRecipientsForProfilePic(file.url)) { + val avatarFile = AvatarHelper.getAvatarFile(context, address) + if (avatarFile.exists()) { + val data = runCatching { avatarFile.readBytes() } + .onFailure { Log.w(TAG, "Error reading old avatar file", it) } + .getOrNull() ?: continue + + val meta = FileMetadata( + expiryTime = if (address.address == prefs.getLocalNumber()) { + TextSecurePreferences.getProfileExpiry(context).millsToInstant() + } else { + null + } + ) + + Log.d( + TAG, + "Migrated old avatar file for ${address.debugString} to new location" + ) + + avatarFile.delete() + return data.view() to meta + } + } + + val result = fileServerApi.parseAttachmentUrl(file.url.toHttpUrl()) + + val response = fileServerApi.download( + fileId = result.fileId, + fileServer = result.fileServer, + ) + + Log.d(TAG, "Downloaded file from file server: $file") + + // Decrypt data + val decrypted = if (result.usesDeterministicEncryption) { + attachmentProcessor.decryptDeterministically( + ciphertext = response.body, + key = file.key.data + ) + } else { + AESGCM.decrypt( + ivAndCiphertext = response.body.data, + offset = response.body.offset, + len = response.body.len, + symmetricKey = file.key.data + ).view() + } + + decrypted to FileMetadata(expiryTime = response.expires?.toInstant()) + + } + + is RemoteFile.Community -> { + val data = OpenGroupApi.download( + fileId = file.fileId, + room = file.roomId, + server = file.communityServerBaseUrl + ) + + data to FileMetadata() + } + } + } + + companion object { + private const val TAG = "AvatarDownloadManager" + private const val SUBDIRECTORY = "remote_files" + + private fun RemoteFile.sha256Hash(): String { + val hash = MessageDigest.getInstance("SHA-256") + when (this) { + is RemoteFile.Encrypted -> { + hash.update(url.lowercase().trim().toByteArray()) + hash.update(key.data) + } + + is RemoteFile.Community -> { + hash.update(communityServerBaseUrl.lowercase().trim().toByteArray()) + hash.update(roomId.lowercase().trim().toByteArray()) + hash.update(fileId.lowercase().trim().toByteArray()) + } + } + return hash.digest().toHexString() + } + + private fun downloadsDirectory(context: Context): File = + File(context.cacheDir, SUBDIRECTORY) + + // Deterministically get the file path for the given remote file. + fun computeFileName(context: Context, remote: RemoteFile): File { + return File(downloadsDirectory(context), remote.sha256Hash()) + } + + /** Returns all currently downloaded files (may be empty). */ + fun listDownloadedFiles(context: Context): List { + val directory = downloadsDirectory(context) + return directory.listFiles()?.toList().orEmpty() + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt new file mode 100644 index 0000000000..ac6369708a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -0,0 +1,236 @@ +package org.thoughtcrime.securesms.attachments + +import android.content.Context +import android.widget.Toast +import androidx.compose.ui.unit.IntSize +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig +import okhttp3.HttpUrl.Companion.toHttpUrl +import okio.BufferedSource +import okio.buffer +import okio.source +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.CurrentActivityObserver +import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant +import org.thoughtcrime.securesms.util.ImageUtils +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit + +/** + * A worker that periodically checks if the user's avatar needs to be re-uploaded to the server, + * and when so, reprocesses and re-uploads it, or renews it on the server if no reprocessing is needed. + * + * Right now we are scheduling it to run once daily, this should give us plenty of leeway to ensure + * that the avatar is always available on the server (the default ttl is 14 days). + */ +@HiltWorker +class AvatarReuploadWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted params: WorkerParameters, + private val attachmentProcessor: AttachmentProcessor, + private val configFactory: ConfigFactoryProtocol, + private val avatarUploadManager: Lazy, + private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, + private val fileServerApi: FileServerApi, + private val prefs: TextSecurePreferences, + private val currentActivityObserver: CurrentActivityObserver, +) : CoroutineWorker(context, params) { + + /** + * Log the given message and show a toast if in debug mode + */ + private suspend inline fun logAndToast(message: String, e: Throwable? = null) { + Log.d(TAG, message, e) + + val context = currentActivityObserver.currentActivity.value ?: return + if (prefs.debugAvatarReupload || BuildConfig.DEBUG) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "AvatarReupload[debug only]: $message", Toast.LENGTH_SHORT).show() + } + } + } + + override suspend fun doWork(): Result { + val (profile, lastUpdated) = configFactory.withUserConfigs { configs -> + configs.userProfile.getPic().toRemoteFile() to configs.userProfile.getProfileUpdatedSeconds().secondsToInstant() + } + + if (profile == null) { + logAndToast("No profile picture set; nothing to do.") + return Result.success() + } + + val localFile = AvatarDownloadManager.computeFileName(context, profile) + if (!localFile.exists()) { + logAndToast("Avatar file is missing locally; nothing to do.") + return Result.success() + } + + val fileExpiry: Instant? + + // Check if the file exists and whether we need to do reprocessing, if we do, we reprocess and re-upload + localEncryptedFileInputStreamFactory.create(localFile).use { stream -> + if (stream.meta.hasPermanentDownloadError) { + logAndToast("Permanent download error for current avatar; nothing to do.") + return Result.success() + } + + fileExpiry = stream.meta.expiryTime + + val source = stream.source().buffer() + + if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { + logAndToast("About to start reuploading avatar.") + val attachment = attachmentProcessor.processAvatar( + data = source.use { it.readByteArray() }, + ) ?: return Result.failure() + + Log.d(TAG, "Reuploading avatar with mimeType=${attachment.mimeType}, size=${attachment.imageSize}") + + try { + avatarUploadManager.get().uploadAvatar( + pictureData = attachment.data, + isReupload = true + ) + } catch (e: CancellationException) { + throw e + } catch (e: NonRetryableException) { + logAndToast("Non-retryable error while reuploading avatar.", e) + return Result.failure() + } catch (e: Exception) { + logAndToast("Error while reuploading avatar.", e) + return Result.retry() + } + + logAndToast("Successfully reuploaded avatar.") + return Result.success() + } + } + + // Otherwise, we only need to renew the same avatar on the server + val parsed = fileServerApi.parseAttachmentUrl(profile.url.toHttpUrl()) + + logAndToast("Renewing user avatar on ${parsed.fileServer}") + try { + fileServerApi.renew( + fileId = parsed.fileId, + fileServer = parsed.fileServer, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // When renew fails, we will try to re-upload the avatar if: + // 1. The file is expired (we have the record of this file's expiry time), or + // 2. The last update was more than 12 days ago. + if ((e is NonRetryableException || e is OnionRequestAPI.HTTPRequestFailedAtDestinationException)) { + val now = Instant.now() + if (fileExpiry?.isBefore(now) == true || + (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { + logAndToast("FileServer renew failed, trying to upload", e) + val pictureData = + localEncryptedFileInputStreamFactory.create(localFile).use { stream -> + check(!stream.meta.hasPermanentDownloadError) { + "Permanent download error for avatar where we expect to to false." + } + stream.readBytes() + } + + try { + avatarUploadManager.get().uploadAvatar( + pictureData = pictureData, + isReupload = true + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logAndToast("Error while reuploading avatar after renew failed.", e) + return Result.failure() + } + + logAndToast("Successfully reuploaded avatar after renew failed.") + } else { + logAndToast( "Not reuploading avatar after renew failed; last updated too recent.") + } + + return Result.success() + } else { + logAndToast("Error while renewing avatar. Retrying...", e) + return Result.retry() + } + } + + return Result.success() + } + + private fun needsReProcessing(source: BufferedSource): Boolean { + if (ImageUtils.isPng(source)) { + return true + } + val bounds = readImageBounds(source) + Log.d(TAG, "Old avatar bounds: $bounds") + return bounds.width > AttachmentProcessor.MAX_AVATAR_SIZE_PX.width + || bounds.height > AttachmentProcessor.MAX_AVATAR_SIZE_PX.height + } + + + companion object { + private const val TAG = "AvatarReuploadWorker" + + private const val UNIQUE_WORK_NAME = "avatar-reupload" + + private fun readImageBounds(source: BufferedSource): IntSize { + val r = source.peek().inputStream().use(BitmapUtil::getDimensions) + return IntSize(r.first, r.second) + } + + suspend fun schedule(context: Context, prefs: TextSecurePreferences) { + Log.d(TAG, "Scheduling avatar reupload worker.") + + val request = if (BuildConfig.DEBUG || prefs.debugAvatarReupload) { + PeriodicWorkRequestBuilder( + Duration.ofMinutes(15) + ).setInitialDelay(0L, TimeUnit.MILLISECONDS) + } else { + PeriodicWorkRequestBuilder( + Duration.ofDays(1), + Duration.ofHours(12) + ) + } + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofMinutes(15)) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request) + .await() + } + + fun cancel(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(UNIQUE_WORK_NAME) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index 92a4bd51f2..b4bd8db154 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -1,52 +1,32 @@ package org.thoughtcrime.securesms.attachments import android.app.Application -import android.os.FileObserver -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.encrypt.Attachments import network.loki.messenger.libsession_util.util.Bytes -import okio.Buffer import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile -import org.session.libsession.utilities.userConfigsChanged -import org.session.libsignal.streams.DigestingRequestBody -import org.session.libsignal.streams.ProfileCipherOutputStream -import org.session.libsignal.streams.ProfileCipherOutputStreamFactory import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ProfileAvatarData import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent -import org.thoughtcrime.securesms.util.DateUtils.Companion.millsToInstant import org.thoughtcrime.securesms.util.castAwayType -import java.io.ByteArrayInputStream -import java.io.File -import java.security.SecureRandom -import java.time.Instant import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume import kotlin.time.Duration -import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds @@ -59,91 +39,39 @@ class AvatarUploadManager @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val prefs: TextSecurePreferences, @ManagerScope scope: CoroutineScope, - localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory + private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, + private val fileServerApi: FileServerApi, + private val attachmentProcessor: AttachmentProcessor, ) : OnAppStartupComponent { - @OptIn(ExperimentalCoroutinesApi::class) - val reuploadState: StateFlow = prefs.watchLocalNumber() - .map { it != null } - .flatMapLatest { isLoggedIn -> - if (isLoggedIn) { - configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.USER_PROFILE)) + init { + // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state + scope.launch { + combine( + prefs.watchLocalNumber() + .map { it != null } + .distinctUntilChanged(), + TextSecurePreferences._events.filter { it == TextSecurePreferences.DEBUG_AVATAR_REUPLOAD } .castAwayType() .onStart { emit(Unit) } - .map { - configFactory.withUserConfigs { it.userProfile.getPic() } - .takeIf { it.url.isNotBlank() } - ?.toRemoteFile() - } - .filterNotNull() - .map { RemoteFileDownloadWorker.computeFileName(application, it) } - .distinctUntilChanged() - .mapLatest { localFile -> - waitUntilExists(localFile) - Log.d(TAG, "About to look at file $localFile for re-upload") - - val expiringIn = runCatching { - localEncryptedFileInputStreamFactory.create(localFile) - .use { it.meta.expiryTime } - }.onFailure { - Log.w(TAG, "Failed to read expiry time from $localFile", it) - }.getOrNull() ?: (localFile.lastModified() + DEFAULT_AVATAR_TTL.inWholeMilliseconds).millsToInstant()!! - - Log.d(TAG, "Avatar expiring at $expiringIn") - val now = Instant.now() - if (expiringIn.isAfter(now)) { - delay(expiringIn.toEpochMilli() - now.toEpochMilli()) - } - - Log.d(TAG, "Avatar expired, re-uploading") - try { - uploadAvatar( - pictureData = localEncryptedFileInputStreamFactory.create(localFile) - .use { it.readBytes() }, - isReupload = true, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Failed to re-upload avatar", e) - } - } - } else { - emptyFlow() - } - } - .stateIn(scope, SharingStarted.Eagerly, Unit) - - private suspend fun waitUntilExists(file: File) { - if (file.exists()) return - - // First make sure its parent directory exists so we can observe it - file.parentFile!!.mkdirs() - - var fileObserver: FileObserver - - return suspendCancellableCoroutine { cont -> - // The fileObserver variable MUST exist until the coroutine is resumed or cancelled, - // otherwise the observer will be garbage collected and the coroutine will never resume. - @Suppress("DEPRECATION") - fileObserver = object : FileObserver( - file.parentFile!!.absolutePath, - CREATE or MOVED_TO - ) { - override fun onEvent(event: Int, path: String?) { - Log.d(TAG, "FileObserver event: $event, path: $path") - if (path == file.name) { - stopWatching() - cont.resume(Unit) + ) { loggedIn, _ -> loggedIn } + .collectLatest { loggedIn -> + if (loggedIn) { + AvatarReuploadWorker.schedule(application, prefs) + } else { + AvatarReuploadWorker.cancel(application) } } - } - - fileObserver.startWatching() } } + /** + * Uploads the given avatar image data to the file server, updates the user profile to point to + * the new avatar, and deletes any old avatar from local storage. + * + * @param pictureData The raw image data of the avatar to upload. Should be unencrypted real image data. + * @param isReupload Whether this is a re-upload of an existing avatar. + */ suspend fun uploadAvatar( pictureData: ByteArray, isReupload: Boolean, // Whether this is a re-upload of an existing avatar @@ -152,40 +80,32 @@ class AvatarUploadManager @Inject constructor( "Should not upload an empty avatar" } - val inputStream = ByteArrayInputStream(pictureData) - val outputStream = - ProfileCipherOutputStream.getCiphertextLength(pictureData.size.toLong()) - val profileKey = ByteArray(PROFILE_KEY_LENGTH) - .also(SecureRandom()::nextBytes) - - val pad = ProfileAvatarData( - inputStream, - outputStream, - "image/jpeg", - ProfileCipherOutputStreamFactory(profileKey) - ) - val drb = DigestingRequestBody( - pad.data, - pad.outputStreamFactory, - pad.contentType, - pad.dataLength, - ) - val b = Buffer() - drb.writeTo(b) - val data = b.readByteArray() + val usesDeterministicEncryption = prefs.forcesDeterministicAttachmentEncryption + val result = if (usesDeterministicEncryption) { + attachmentProcessor.encryptDeterministically( + plaintext = pictureData, + domain = Attachments.Domain.ProfilePic + ) + } else { + val key = Util.getSecretBytes(PROFILE_KEY_LENGTH) + val ciphertext = AESGCM.encrypt(pictureData, key) + AttachmentProcessor.EncryptResult(ciphertext = ciphertext, key = key) + } - val uploadResult = FileServerApi.upload( - file = data, + val uploadResult = fileServerApi.upload( + file = result.ciphertext, + fileServer = prefs.alternativeFileServer ?: FileServerApi.DEFAULT_FILE_SERVER, + usedDeterministicEncryption = usesDeterministicEncryption, customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } - ).await() + ) Log.d(TAG, "Avatar upload finished with $uploadResult") - val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(profileKey)) + val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(result.key)) // To save us from downloading this avatar again, we store the data as it would be downloaded localEncryptedFileOutputStreamFactory.create( - file = RemoteFileDownloadWorker.computeFileName(application, remoteFile), + file = AvatarDownloadManager.computeFileName(application, remoteFile), meta = FileMetadata(expiryTime = uploadResult.expires?.toInstant()) ).use { it.write(pictureData) @@ -212,7 +132,7 @@ class AvatarUploadManager @Inject constructor( if (oldPic != null) { // If we had an old avatar, delete it from local storage - val oldFile = RemoteFileDownloadWorker.computeFileName(application, oldPic) + val oldFile = AvatarDownloadManager.computeFileName(application, oldPic) if (oldFile.exists()) { Log.d(TAG, "Deleting old avatar file: $oldFile") oldFile.delete() @@ -225,7 +145,6 @@ class AvatarUploadManager @Inject constructor( private const val PROFILE_KEY_LENGTH = 32 - private val DEFAULT_AVATAR_TTL: Duration = 14.days private val DEBUG_AVATAR_TTL: Duration = 30.seconds } } \ No newline at end of file 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 f9b28899c6..1a293e2199 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -2,7 +2,11 @@ package org.thoughtcrime.securesms.attachments import android.content.Context import android.text.TextUtils +import androidx.compose.ui.unit.IntSize import com.google.protobuf.ByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import okio.buffer +import okio.source import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.sending_receiving.attachments.Attachment @@ -24,74 +28,79 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.Database +import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MessagingDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.MediaStream import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil +import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream +import javax.inject.Inject import javax.inject.Provider - -class DatabaseAttachmentProvider(context: Context, helper: Provider) : Database(context, helper), MessageDataProvider { +import javax.inject.Singleton + +@Singleton +class DatabaseAttachmentProvider @Inject constructor( + @ApplicationContext context: Context, + helper: Provider, + private val attachmentDatabase: AttachmentDatabase, + private val mmsDatabase: MmsDatabase, + private val smsDatabase: SmsDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val lokiMessageDatabase: LokiMessageDatabase, + private val attachmentProcessor: AttachmentProcessor, +) : Database(context, helper), MessageDataProvider { override fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() val databaseAttachment = attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) ?: return null return databaseAttachment.toAttachmentStream(context) } override fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() val databaseAttachment = attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) ?: return null return databaseAttachment.toAttachmentPointer() } override fun getSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() val databaseAttachment = attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) ?: return null return databaseAttachment.toSignalAttachmentStream(context) } - override fun getScaledSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? { - val database = DatabaseComponent.get(context).attachmentDatabase() - val databaseAttachment = database.getAttachment(AttachmentId(attachmentId, 0)) ?: return null + override suspend fun getScaledSignalAttachmentStream(attachmentId: Long): SignalServiceAttachmentStream? { + val id = AttachmentId(attachmentId, 0) + val databaseAttachment = attachmentDatabase.getAttachment(id) ?: return null val mediaConstraints = MediaConstraints.getPushMediaConstraints() - val scaledAttachment = scaleAndStripExif(database, mediaConstraints, databaseAttachment) ?: return null + val scaledAttachment = processAttachment(attachmentDatabase, mediaConstraints, databaseAttachment) ?: databaseAttachment return getAttachmentFor(scaledAttachment) } override fun getSignalAttachmentPointer(attachmentId: Long): SignalServiceAttachmentPointer? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() val databaseAttachment = attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) ?: return null return databaseAttachment.toSignalAttachmentPointer() } override fun setAttachmentState(attachmentState: AttachmentState, attachmentId: AttachmentId, messageID: Long) { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() attachmentDatabase.setTransferState(attachmentId, attachmentState.value) } - override fun getMessageForQuote(timestamp: Long, author: Address): Triple? { - val messagingDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - val message = messagingDatabase.getMessageFor(timestamp, author) + override fun getMessageForQuote(threadId: Long, timestamp: Long, author: Address): Triple? { + val message = mmsSmsDatabase.getMessageFor(threadId, timestamp, author) return if (message != null) Triple(message.id, message.isMms, message.body) else null } override fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List { - return DatabaseComponent.get(context).attachmentDatabase().getAttachmentsForMessage(mmsId) - } - - override fun getMessageBodyFor(timestamp: Long, author: String): String { - val messagingDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - return messagingDatabase.getMessageFor(timestamp, author)!!.body + return attachmentDatabase.getAttachmentsForMessage(mmsId) } override fun getAttachmentIDsFor(mmsMessageId: Long): List { - return DatabaseComponent.get(context) - .attachmentDatabase() + return attachmentDatabase .getAttachmentsForMessage(mmsMessageId).mapNotNull { if (it.isQuote) return@mapNotNull null it.attachmentId.rowId @@ -99,12 +108,11 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider, threadId: Long): Pair, List> { - val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() - return messageDB.getMessageIDs(serverIds, threadId) + return lokiMessageDatabase.getMessageIDs(serverIds, threadId) } override fun getUserMessageHashes(threadId: Long, userPubKey: String): List { - val component = DatabaseComponent.get(context) - val messages = component.mmsSmsDatabase().getUserMessages(threadId, userPubKey) - val messageDatabase = component.lokiMessageDatabase() + val messages = mmsSmsDatabase.getUserMessages(threadId, userPubKey) return messages.mapNotNull { - messageDatabase.getMessageServerHash(it.messageId) + lokiMessageDatabase.getMessageServerHash(it.messageId) } } override fun deleteMessage(messageId: MessageId) { if (messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().deleteMessage(messageId.id) + mmsDatabase.deleteMessage(messageId.id) } else { - DatabaseComponent.get(context).smsDatabase().deleteMessage(messageId.id) + smsDatabase.deleteMessage(messageId.id) } - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageId) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageId) + lokiMessageDatabase.deleteMessage(messageId) + lokiMessageDatabase.deleteMessageServerHash(messageId) } override fun deleteMessages(messageIDs: List, isSms: Boolean) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() - else DatabaseComponent.get(context).mmsDatabase() + val messagingDatabase: MessagingDatabase = if (isSms) smsDatabase + else mmsDatabase messagingDatabase.deleteMessages(messageIDs) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs, isSms = isSms) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) + lokiMessageDatabase.deleteMessages(messageIDs, isSms = isSms) + lokiMessageDatabase.deleteMessageServerHashes(messageIDs, mms = !isSms) } override fun markMessageAsDeleted(messageId: MessageId, displayedMessage: String) { - val database = DatabaseComponent.get(context).mmsSmsDatabase() - val message = database.getMessageById(messageId) ?: return Log.w("", "Failed to find message to mark as deleted") + val message = mmsSmsDatabase.getMessageById(messageId) ?: return Log.w("", "Failed to find message to mark as deleted") markMessagesAsDeleted( messages = listOf(MarkAsDeletedMessage( @@ -222,9 +222,6 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider, displayedMessage: String ) { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - messages.forEach { message -> if (message.messageId.mms) { mmsDatabase.markAsDeleted(message.messageId.id, message.isOutgoing, displayedMessage) @@ -239,7 +236,7 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider, displayedMessage: String ) { - val markAsDeleteMessages = DatabaseComponent.get(context).lokiMessageDatabase() + val markAsDeleteMessages = lokiMessageDatabase .getSendersForHashes(threadId, serverHashes.toSet()) .map { MarkAsDeletedMessage(messageId = it.messageId, isOutgoing = it.isOutgoing) } @@ -252,7 +249,7 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider @@ -264,29 +261,33 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider + attachmentProcessor.process( + data = data, + maxImageResolution = IntSize(constraints.getImageMaxWidth(context), constraints.getImageMaxHeight(context)), + compressImage = false, + ) + } ?: return null + + attachmentDatabase.updateAttachmentData( + attachment, + MediaStream(ByteArrayInputStream(result.data), result.mimeType, result.imageSize.width, result.imageSize.height) + ) } catch (e: Exception) { - return null + Log.e(TAG, "Error processing attachment", e) + throw e } } @@ -310,8 +311,11 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider(ARG_ENCRYPTED_URL) -> RemoteFile.Encrypted( - url = inputData.getString(ARG_ENCRYPTED_URL)!!, - key = Bytes(requireNotNull(inputData.getByteArray(ARG_ENCRYPTED_KEY))) - ) - - else -> RemoteFile.Community( - communityServerBaseUrl = requireNotNull(inputData.getString(ARG_COMMUNITY_URL)) { - "RemoteFileDownloadWorker requires a community URL" - }, - roomId = requireNotNull(inputData.getString(ARG_COMMUNITY_ROOM_ID)) { - "RemoteFileDownloadWorker requires a community room ID" - }, - fileId = requireNotNull(inputData.getString(ARG_COMMUNITY_FILE_ID)) { - "RemoteFileDownloadWorker requires a community file ID" - } - ) - } - } - - override suspend fun doWork(): Result { - val downloaded = computeFileName(context, file) - - // If the downloaded file exists, it can either mean it's already downloaded or the previous - // download failed permanently, so we can skip it. - if (downloaded.exists()) { - try { - localEncryptedFileInputStreamFactory.create(downloaded).use { - if (it.meta.hasPermanentDownloadError) { - Log.w(TAG, "File $downloaded is marked as a permanent error, skipping download") - return Result.failure() - } else { - Log.i(TAG, "File $downloaded already exists, skipping download") - return Result.success() - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Failed to read downloaded file $downloaded", e) - // If we can't read the file, we assume it's corrupted and we need to delete it. - if (!downloaded.delete()) { - Log.w(TAG, "Failed to delete corrupted file $downloaded") - } - } - } - - Log.d(TAG, "Start downloading file from $file") - - val result = runCatching { downloadAndDecryptFile() } - - val (bytes, meta) = if (result.isSuccess) { - result.getOrThrow() - } else if (result.exceptionOrNull() is NonRetryableException || - result.exceptionOrNull()?.getRootCause()?.statusCode == 404) { - Log.w(TAG, "Download failed permanently for file $file", result.exceptionOrNull()) - // Write an empty file with a permanent error metadata if the download failed permanently. - byteArrayOf().view() to FileMetadata( - hasPermanentDownloadError = true - ) - } else { - Log.w(TAG, "Download failed for file $file", result.exceptionOrNull()) - // If the download failed otherwise, we retry the work. - return Result.retry() - } - - // A temp file to clear, if it exists - var tmpFileToClean: File? = null - try { - // Re-encrypt the file with our streaming cipher, and encode it with the metadata, - // and doing it to a temporary file first. - downloaded.parentFile!!.mkdirs() - val tmpFile = File.createTempFile("downloaded-", null, downloaded.parentFile) - .also { tmpFileToClean = it } - - localEncryptedFileOutputStreamFactory.create(tmpFile, meta) - .use { fos -> fos.write(bytes) } - - // Once done, rename the temporary file to the final file name. - check(tmpFile.renameTo(downloaded)) { - "Failed to rename temporary file ${tmpFile.absolutePath} to $downloaded" - } - - // Since we successfully moved the file, we don't need to delete the temporary file anymore. - tmpFileToClean = null - Log.d(TAG, "Successfully downloaded file $file") - return Result.success() - } catch (e: CancellationException) { - Log.i(TAG, "Download cancelled for file $file") - throw e - } catch (e: Exception) { - Log.e(TAG, "Failed to download file $file", e) - return Result.failure() - } finally { - tmpFileToClean?.delete() - } - } - - private fun findRecipientsForProfilePic(profilePicUrl: String): Set
{ - return buildSet { - addAll(recipientSettingsDatabase.findRecipientsForProfilePic(profilePicUrl)) - - val allGroups = configFactory.withUserConfigs { configs -> - if (configs.userProfile.getPic().url == profilePicUrl) { - // If the profile picture URL matches the one in the user config, add the local number - // as a recipient as well. - add(Address.fromSerialized(prefs.getLocalNumber()!!)) - } - - // Search through all contacts to find any that have this profile picture URL. - configs.contacts.all() - .asSequence() - .filter { it.profilePicture.url == profilePicUrl } - .mapTo(this) { it.id.toAddress() } - - // Search through all blinded contacts to find any that have this profile picture URL. - configs.contacts.allBlinded() - .asSequence() - .filter { it.profilePic.url == profilePicUrl } - .mapTo(this) { it.id.toAddress() } - - // Return the group addresses for further processing. - configs.userGroups.allClosedGroupInfo() - } - - for (group in allGroups) { - configFactory.withGroupConfigs(AccountId(group.groupAccountId)) { configs -> - configs.groupMembers.all() - .asSequence() - .filter { it.profilePic()?.url == profilePicUrl } - .mapTo(this) { it.accountId().toAddress() } - } - } - } - } - - private suspend fun downloadAndDecryptFile(): Pair { - return when (val file = file) { - is RemoteFile.Encrypted -> { - // Look at the db and find out which addresses have this file as their profile picture, - // then we will look at the old location of the avatar file and migrate it to the new location. - // Once the migration grace period is over, this copying code shall be removed. - for (address in findRecipientsForProfilePic(file.url)) { - val avatarFile = AvatarHelper.getAvatarFile(context, address) - if (avatarFile.exists()) { - val data = runCatching { avatarFile.readBytes() } - .onFailure { Log.w(TAG, "Error reading old avatar file", it) } - .getOrNull() ?: continue - - val meta = FileMetadata( - expiryTime = if (address.address == prefs.getLocalNumber()) { - TextSecurePreferences.getProfileExpiry(context).millsToInstant() - } else { - null - } - ) - - Log.d(TAG, "Migrated old avatar file for ${address.debugString} to new location") - - avatarFile.delete() - return data.view() to meta - } - } - - val fileId = requireNotNull(FileServerApi.getFileIdFromUrl(file.url)) { - "RemoteFileDownloadWorker currently only supports downloading files from Session's file server" - } - - val response = FileServerApi.download(fileId).await() - Log.d(TAG, "Downloaded file from file server: $file") - - // Decrypt data - val decrypted = AESGCM.decrypt( - ivAndCiphertext = response.body.data, - offset = response.body.offset, - len = response.body.len, - symmetricKey = file.key.data - ) - - decrypted.view() to FileMetadata(expiryTime = response.expires?.toInstant()) - - } - - is RemoteFile.Community -> { - val data = OpenGroupApi.download( - fileId = file.fileId, - room = file.roomId, - server = file.communityServerBaseUrl - ).await() - - data to FileMetadata() - } - } - } - - companion object { - const val TAG = "RemoteFileDownloadWorker" - - private const val ARG_ENCRYPTED_URL = "encrypted_url" - private const val ARG_ENCRYPTED_KEY = "encrypted_key" - - private const val ARG_COMMUNITY_URL = "community_url" - private const val ARG_COMMUNITY_ROOM_ID = "community_room_id" - private const val ARG_COMMUNITY_FILE_ID = "community_file_id" - - private const val SUBDIRECTORY = "remote_files" - - private fun RemoteFile.sha256Hash(): String { - val hash = MessageDigest.getInstance("SHA-256") - when (this) { - is RemoteFile.Encrypted -> { - hash.update(url.lowercase().trim().toByteArray()) - hash.update(key.data) - } - - is RemoteFile.Community -> { - hash.update(communityServerBaseUrl.lowercase().trim().toByteArray()) - hash.update(roomId.lowercase().trim().toByteArray()) - hash.update(fileId.lowercase().trim().toByteArray()) - } - } - return hash.digest().toHexString() - } - - private fun downloadsDirectory(context: Context): File = - File(context.cacheDir, SUBDIRECTORY) - - // Deterministically get the file path for the given remote file. - fun computeFileName(context: Context, remote: RemoteFile): File { - return File(downloadsDirectory(context), remote.sha256Hash()) - } - - fun cancelAll(context: Context) { - WorkManager.getInstance(context).cancelAllWorkByTag(TAG) - } - - private fun uniqueWorkName(remote: RemoteFile): String { - return "download-remote-file-${remote.sha256Hash()}" - } - - fun cancel(context: Context, file: RemoteFile): Operation { - return WorkManager.getInstance(context).cancelUniqueWork( - uniqueWorkName(file) - ) - } - - /** Returns all currently downloaded files (may be empty). */ - fun listDownloadedFiles(context: Context): List { - val directory = downloadsDirectory(context) - return directory.listFiles()?.toList().orEmpty() - } - - /** - * @param isOldAvatarOf used to indicate that this file is an avatar of a specific address. This - * information is optional and only used for migration purposes (to move the avatar from - * the old location to the new one). Once the migration grace period is over, this parameter - * shall be removed. - */ - fun enqueue(context: Context, file: RemoteFile): Flow { - val input = when (file) { - is RemoteFile.Encrypted -> Data.Builder() - .putString(ARG_ENCRYPTED_URL, file.url) - .putByteArray(ARG_ENCRYPTED_KEY, file.key.data) - - is RemoteFile.Community -> Data.Builder() - .putString(ARG_COMMUNITY_URL, file.communityServerBaseUrl) - .putString(ARG_COMMUNITY_ROOM_ID, file.roomId) - .putString(ARG_COMMUNITY_FILE_ID, file.fileId) - } - - val request = OneTimeWorkRequestBuilder() - .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(5)) - .addTag(TAG) - .setInputData(input.build()) - .build() - - val workName = uniqueWorkName(file) - WorkManager.getInstance(context) - .enqueueUniqueWork( - workName, - ExistingWorkPolicy.KEEP, - request - ) - - return WorkManager.getInstance(context) - .getWorkInfosForUniqueWorkFlow(workName) - .mapNotNull { it.firstOrNull() } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt index 1b02e282a7..540f41049e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt @@ -1,57 +1,47 @@ package org.thoughtcrime.securesms.coil import android.content.Context -import androidx.work.WorkInfo import coil3.ImageLoader import coil3.decode.DataSource import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult +import coil3.request.CachePolicy import coil3.request.Options import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first import okio.FileSystem import okio.buffer import okio.source import org.session.libsession.utilities.recipients.RemoteFile -import org.session.libsignal.exceptions.NonRetryableException -import org.thoughtcrime.securesms.attachments.LocalEncryptedFileInputStream -import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker +import org.thoughtcrime.securesms.attachments.AvatarDownloadManager class RemoteFileFetcher @AssistedInject constructor( @Assisted private val file: RemoteFile, + @Assisted private val options: Options, @param:ApplicationContext private val context: Context, - val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, + private val avatarDownloadManager: AvatarDownloadManager, ) : Fetcher { override suspend fun fetch(): FetchResult? { - val downloadedFile = RemoteFileDownloadWorker.computeFileName(context, file) + val downloadedFile = AvatarDownloadManager.computeFileName(context, file) // Check if the file already exists in the local storage, otherwise enqueue a download and // wait for it to complete. - val dataSource = if (!downloadedFile.exists()) { - RemoteFileDownloadWorker.enqueue(context, file) - .first { it?.state == WorkInfo.State.FAILED || it?.state == WorkInfo.State.SUCCEEDED } - DataSource.NETWORK - } else { - DataSource.DISK - } - - val stream = localEncryptedFileInputStreamFactory.create(downloadedFile) - - if (stream.meta.hasPermanentDownloadError) { - stream.close() - throw NonRetryableException( - "File download failed permanently for $file" - ) + val dataSource = when { + downloadedFile.exists() -> DataSource.DISK + options.networkCachePolicy == CachePolicy.ENABLED -> DataSource.NETWORK + else -> { + throw RuntimeException("RemoteFile doesn't exist locally and we aren't allowed to download" + + "from network") + } } return SourceFetchResult( source = ImageSource( - source = stream.source().buffer(), + source = avatarDownloadManager.download(file).source().buffer(), fileSystem = FileSystem.SYSTEM, metadata = null ), @@ -62,14 +52,14 @@ class RemoteFileFetcher @AssistedInject constructor( @AssistedFactory abstract class Factory : Fetcher.Factory { - abstract fun create(remoteFile: RemoteFile): RemoteFileFetcher + abstract fun create(remoteFile: RemoteFile, options: Options): RemoteFileFetcher override fun create( data: RemoteFile, options: Options, imageLoader: ImageLoader ): Fetcher? { - return create(data) + return create(data, options) } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java deleted file mode 100644 index a0646bc532..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ /dev/null @@ -1,160 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.os.Bundle; -import android.text.InputType; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.TextUtils.TruncateAt; -import android.text.style.RelativeSizeSpan; -import android.util.AttributeSet; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatEditText; -import androidx.core.os.BuildCompat; -import androidx.core.view.inputmethod.EditorInfoCompat; -import androidx.core.view.inputmethod.InputConnectionCompat; -import androidx.core.view.inputmethod.InputContentInfoCompat; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Log; - -public class ComposeText extends AppCompatEditText { - - private CharSequence hint; - private SpannableString subHint; - - @Nullable private InputPanel.MediaListener mediaListener; - @Nullable private CursorPositionChangedListener cursorPositionChangedListener; - - public ComposeText(Context context) { - super(context); - initialize(); - } - - public ComposeText(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public String getTextTrimmed(){ - return getText().toString().trim(); - } - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - - if (!TextUtils.isEmpty(hint)) { - if (!TextUtils.isEmpty(subHint)) { - setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint)) - .append("\n") - .append(ellipsizeToWidth(subHint))); - } else { - setHint(ellipsizeToWidth(hint)); - } - } - } - - @Override - protected void onSelectionChanged(int selStart, int selEnd) { - super.onSelectionChanged(selStart, selEnd); - - if (cursorPositionChangedListener != null) { - cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd); - } - } - - private CharSequence ellipsizeToWidth(CharSequence text) { - return TextUtils.ellipsize(text, - getPaint(), - getWidth() - getPaddingLeft() - getPaddingRight(), - TruncateAt.END); - } - - public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { - this.hint = hint; - - if (subHint != null) { - this.subHint = new SpannableString(subHint); - this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - this.subHint = null; - } - - if (this.subHint != null) { - super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint)) - .append("\n") - .append(ellipsizeToWidth(this.subHint))); - } else { - super.setHint(ellipsizeToWidth(this.hint)); - } - } - - @Override - public InputConnection onCreateInputConnection(EditorInfo editorInfo) { - InputConnection inputConnection = super.onCreateInputConnection(editorInfo); - - if(TextSecurePreferences.isEnterSendsEnabled(getContext())) { - editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; - } - - if (mediaListener == null) return inputConnection; - if (inputConnection == null) return null; - - EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"}); - return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); - } - - private void initialize() { - if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { - setImeOptions(getImeOptions() | 16777216); - } - } - - private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener { - - private static final String TAG = CommitContentListener.class.getSimpleName(); - - private final InputPanel.MediaListener mediaListener; - - private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) { - this.mediaListener = mediaListener; - } - - @Override - public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { - if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { - try { - inputContentInfo.requestPermission(); - } catch (Exception e) { - Log.w(TAG, e); - return false; - } - } - - if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { - mediaListener.onMediaSelected(inputContentInfo.getContentUri(), - inputContentInfo.getDescription().getMimeType(0)); - - return true; - } - - return false; - } - } - - public interface CursorPositionChangedListener { - void onCursorPositionChanged(int start, int end); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java deleted file mode 100644 index ad375c0992..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.net.Uri; -import android.util.AttributeSet; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; - -public class InputPanel extends LinearLayout { - - public InputPanel(Context context) { - super(context); - } - - public InputPanel(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public interface MediaListener { - void onMediaSelected(@NonNull Uri uri, String contentType); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java deleted file mode 100644 index 58b6d3a459..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.animation.Animator; -import android.content.Context; -import android.util.AttributeSet; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewAnimationUtils; -import android.widget.EditText; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.appcompat.widget.Toolbar; - -import org.thoughtcrime.securesms.util.AnimationCompleteListener; - -import network.loki.messenger.R; - -public class SearchToolbar extends Toolbar { - - private float x, y; - private MenuItem searchItem; - private SearchListener listener; - - public SearchToolbar(Context context) { - super(context); - initialize(); - } - - public SearchToolbar(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - private void initialize() { - setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_x)); - inflateMenu(R.menu.conversation_list_search); - - this.searchItem = getMenu().findItem(R.id.action_filter_search); - SearchView searchView = (SearchView) searchItem.getActionView(); - EditText searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text); - - searchView.setSubmitButtonEnabled(false); - - if (searchText != null) searchText.setHint(R.string.search); - else searchView.setQueryHint(getResources().getString(R.string.search)); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - if (listener != null) listener.onSearchTextChange(query); - return true; - } - - @Override - public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); } - }); - - searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { - @Override - public boolean onMenuItemActionExpand(MenuItem item) { - return true; - } - - @Override - public boolean onMenuItemActionCollapse(MenuItem item) { - hide(); - return true; - } - }); - - setNavigationOnClickListener(v -> hide()); - } - - @MainThread - public void display(float x, float y) { - if (getVisibility() != View.VISIBLE) { - this.x = x; - this.y = y; - - searchItem.expandActionView(); - - Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth()); - animator.setDuration(400); - - setVisibility(View.VISIBLE); - animator.start(); - } - } - - public void collapse() { - searchItem.collapseActionView(); - } - - @MainThread - private void hide() { - if (getVisibility() == View.VISIBLE) { - - if (listener != null) listener.onSearchClosed(); - - Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0); - animator.setDuration(400); - animator.addListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - setVisibility(View.INVISIBLE); - } - }); - animator.start(); - } - } - - public boolean isVisible() { - return getVisibility() == View.VISIBLE; - } - - @MainThread - public void setListener(SearchListener listener) { - this.listener = listener; - } - - public interface SearchListener { - void onSearchTextChange(String text); - void onSearchClosed(); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java deleted file mode 100644 index 5d369de3b3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.widget.FrameLayout; - -import network.loki.messenger.R; - -public class SquareFrameLayout extends FrameLayout { - - private final boolean squareHeight; - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context) { - this(context, null); - } - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SquareFrameLayout, 0, 0); - this.squareHeight = typedArray.getBoolean(R.styleable.SquareFrameLayout_square_height, false); - typedArray.recycle(); - } else { - this.squareHeight = false; - } - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - //noinspection SuspiciousNameCombination - if (squareHeight) super.onMeasure(heightMeasureSpec, heightMeasureSpec); - else super.onMeasure(widthMeasureSpec, widthMeasureSpec); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index b181a7b1a2..6de900eb76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -62,6 +62,7 @@ import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -70,6 +71,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -170,6 +172,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.groups.GroupMembersActivity import org.thoughtcrime.securesms.groups.OpenGroupManager @@ -204,7 +207,6 @@ import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.PaddedImageSpan import org.thoughtcrime.securesms.util.SaveAttachmentTask -import org.thoughtcrime.securesms.util.adapter.applyImeBottomPadding import org.thoughtcrime.securesms.util.adapter.handleScrollToBottom import org.thoughtcrime.securesms.util.adapter.runWhenLaidOut import org.thoughtcrime.securesms.util.drawToBitmap @@ -263,6 +265,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var openGroupManager: OpenGroupManager @Inject lateinit var attachmentDatabase: AttachmentDatabase @Inject lateinit var clock: SnodeClock + @Inject @ManagerScope + lateinit var scope: CoroutineScope override val applyDefaultWindowInsets: Boolean get() = false @@ -321,7 +325,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Search val searchViewModel: SearchViewModel by viewModels() - private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) + // The channel where we buffer the last seen data to be saved in the db + // The data can be: + // 1. A `MessageId`, which indicates what message we should mark the last seen until (inclusive of reactions) + // 2. A `Long` (timestamp), which indicates we should mark all messages until that timestamp as seen + private val bufferedLastSeenChannel = Channel(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false @@ -595,21 +603,32 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) lifecycleScope.launch { - // only update the conversation every 3 seconds maximum - // channel is rendezvous and shouldn't block on try send calls as often as we want - bufferedLastSeenChannel.receiveAsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collectLatest { - withContext(Dispatchers.IO) { - try { - if (it > storage.getLastSeen(viewModel.threadId)) { - storage.markConversationAsRead(viewModel.threadId, it) + @Suppress("OPT_IN_USAGE") + bufferedLastSeenChannel.receiveAsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .distinctUntilChanged() + .debounce(500L) + .collectLatest { + withContext(Dispatchers.Default) { + try { + when (it) { + is Long -> { + if (storage.getLastSeen(viewModel.threadId) < it) { + storage.markConversationAsRead(viewModel.threadId, it) + } + } + + is MessageId -> { + storage.markConversationAsReadUpToMessage(it) } - } catch (e: Exception) { - Log.e(TAG, "bufferedLastSeenChannel collectLatest", e) + + else -> error("Unsupported type sent to bufferedLastSeenChannel: $it") } + } catch (e: Exception) { + Log.e(TAG, "Error handling last seen", e) } } + } } lifecycleScope.launch { @@ -1373,11 +1392,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val maybeTargetVisiblePosition = layoutManager?.findLastVisibleItemPosition() val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { - adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp -> - bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply { - if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull()) - } + if (binding.conversationRecyclerView.isFullyScrolled) { + adapter.getMessageIdAt(targetVisiblePosition)?.let { lastSeenMessageId -> + bufferedLastSeenChannel.trySend(lastSeenMessageId) + } + } else { + adapter.getMessageTimestampAt(targetVisiblePosition)?.let { timestamp -> + bufferedLastSeenChannel.trySend(timestamp) + } } + } val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } @@ -1748,11 +1772,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when adding emoji reaction") - OpenGroupApi.addReaction( - room = recipient.address.room, - server = recipient.address.serverUrl, - messageId = messageServerId, - emoji = emoji) + scope.launch { + runCatching { + OpenGroupApi.addReaction( + room = recipient.address.room, + server = recipient.address.serverUrl, + messageId = messageServerId, + emoji = emoji + ) + } + } } else { MessageSender.send(reactionMessage, recipient.address) } @@ -1781,16 +1810,31 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ) val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + fromSerialized(viewModel.blindedPublicKey ?: author) } else originalMessage.individualRecipient.address - message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, false) + message.reaction = Reaction.from( + timestamp = originalMessage.timestamp, + author = originalAuthor.address, + emoji = emoji, + react = false + ) + if (recipient.address is Address.Community) { val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") - OpenGroupApi.deleteReaction(recipient.address.room, recipient.address.serverUrl, messageServerId, emoji) + scope.launch { + runCatching { + OpenGroupApi.deleteReaction( + recipient.address.room, + recipient.address.serverUrl, + messageServerId, + emoji + ) + } + } } else { MessageSender.send(message, recipient.address) } @@ -2503,15 +2547,35 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } override fun resyncMessage(messages: Set) { - messages.iterator().forEach { messageRecord -> - ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true) + val accountId = textSecurePreferences.getLocalNumber() + scope.launch { + messages.iterator().forEach { messageRecord -> + runCatching { + ResendMessageUtilities.resend( + accountId, + messageRecord, + viewModel.blindedPublicKey, + isResync = true + ) + } + } } + endActionMode() } override fun resendMessage(messages: Set) { - messages.iterator().forEach { messageRecord -> - ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey) + val accountId = textSecurePreferences.getLocalNumber() + scope.launch { + messages.iterator().forEach { messageRecord -> + runCatching { + ResendMessageUtilities.resend( + accountId, + messageRecord, + viewModel.blindedPublicKey + ) + } + } } endActionMode() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 9f94e9f06d..21efaf7767 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.concurrent.atomic.AtomicLong -import kotlin.math.max import kotlin.math.min class ConversationAdapter( @@ -261,18 +260,20 @@ class ConversationAdapter( notifyDataSetChanged() } - fun getTimestampForItemAt(firstVisiblePosition: Int): Long? { + fun getMessageIdAt(position: Int): MessageId? { val cursor = this.cursor ?: return null - if (!cursor.moveToPosition(firstVisiblePosition)) return null - val message = messageDB.readerFor(cursor).current ?: return null - if (message.reactions.isEmpty()) { - // If the message has no reactions, we can use the timestamp directly - return message.timestamp - } + if (!cursor.moveToPosition(position)) return null + + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)) + val isMms = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)) == MmsSmsDatabase.MMS_TRANSPORT + return MessageId(id, isMms) + } + + fun getMessageTimestampAt(position: Int): Long? { + val cursor = this.cursor ?: return null + if (!cursor.moveToPosition(position)) return null - // Otherwise, we will need to take the reaction timestamp into account - val maxReactionTimestamp = message.reactions.maxOf { it.dateReceived } - return max(message.timestamp, maxReactionTimestamp) + return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)) } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 2d72b4cf5a..89c980c0c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1257,12 +1257,14 @@ class ConversationViewModel @AssistedInject constructor( reactionDb.deleteEmojiReactions(emoji, messageId) (address as? Address.Community)?.let { openGroup -> lokiMessageDb.getServerID(messageId)?.let { serverId -> - OpenGroupApi.deleteAllReactions( - openGroup.room, - openGroup.serverUrl, - serverId, - emoji - ) + runCatching { + OpenGroupApi.deleteAllReactions( + openGroup.room, + openGroup.serverUrl, + serverId, + emoji + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 4162074c0e..8796834bbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -277,10 +277,10 @@ class VisibleMessageView : FrameLayout { if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { emojiReactionsBinding.value.root.let { root -> root.setReactions(message.messageId, message.reactions, message.isOutgoing, delegate) - root.isVisible = true - (root.layoutParams as ConstraintLayout.LayoutParams).apply { + root.layoutParams = (root.layoutParams as ConstraintLayout.LayoutParams).apply { horizontalBias = if (message.isOutgoing) 1f else 0f } + root.isVisible = true } } else if (emojiReactionsBinding.isInitialized()) { emojiReactionsBinding.value.root.isVisible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index cf00cd0f9d..2f47041204 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import android.content.Context import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.visible.LinkPreview @@ -9,7 +8,6 @@ import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.toGroupString import org.thoughtcrime.securesms.database.model.MessageRecord @@ -17,7 +15,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord object ResendMessageUtilities { - fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { + suspend fun resend(accountId: String?, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient = messageRecord.recipient.address val message = VisibleMessage() message.id = messageRecord.messageId @@ -45,7 +43,7 @@ object ResendMessageUtilities { messageRecord.linkPreviews.firstOrNull()?.let { message.linkPreview = LinkPreview.from(it) } messageRecord.quote?.quoteModel?.let { message.quote = Quote.from(it)?.apply { - if (userBlindedKey != null && publicKey == TextSecurePreferences.getLocalNumber(context)) { + if (userBlindedKey != null && publicKey == accountId) { publicKey = userBlindedKey } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 466f862518..383210241d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -52,6 +52,7 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.MmsDatabase.Companion.MESSAGE_BOX import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId @@ -1298,8 +1299,9 @@ class MmsDatabase @Inject constructor( private fun getQuote(cursor: Cursor): Quote? { val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null - val retrievedQuote = mmsSmsDatabase.get().getMessageFor(quoteId, quoteAuthor, false) + val retrievedQuote = mmsSmsDatabase.get().getMessageFor(threadId, quoteId, quoteAuthor, false) val quoteText = retrievedQuote?.body val quoteMissing = retrievedQuote == null val quoteDeck = ( @@ -1451,6 +1453,9 @@ class MmsDatabase @Inject constructor( WHERE ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) != 0; """ + const val ADD_LAST_MESSAGE_INDEX: String = + "CREATE INDEX mms_thread_id_date_sent_index ON $TABLE_NAME ($THREAD_ID, $DATE_SENT)" + private val MMS_PROJECTION: Array = arrayOf( "$TABLE_NAME.$ID AS $ID", THREAD_ID, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 1e6c21e67e..038c7fc6f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -30,7 +30,6 @@ import androidx.annotation.Nullable; import net.zetetic.database.sqlcipher.SQLiteDatabase; -import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; import org.jetbrains.annotations.NotNull; import org.session.libsession.messaging.utilities.UpdateMessageData; @@ -107,7 +106,7 @@ public MmsSmsDatabase(Context context, Provider databaseHel final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } @@ -124,13 +123,15 @@ public MmsSmsDatabase(Context context, Provider databaseHel } } - public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) { - return getMessageFor(timestamp, serializedAuthor, true); + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor) { + return getMessageFor(threadId, timestamp, serializedAuthor, true); } - public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor, boolean getQuote) { + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor, boolean getQuote) { + String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; @@ -148,50 +149,26 @@ public MmsSmsDatabase(Context context, Provider databaseHel return null; } - public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) { - // Early exit if the author is not us - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); - if (!isOwnNumber) { - Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null."); - return null; - } - - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { - MmsSmsDatabase.Reader reader = readerFor(cursor); + /** + * @deprecated We shouldn't be querying messages by timestamp alone. Use `getMessageFor` when possible + */ + @Deprecated(forRemoval = true) + public @Nullable MessageRecord getMessageByTimestamp(long timestamp, String serializedAuthor, boolean getQuote) { + try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, true, null, null, null)) { + MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; + boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); + while ((messageRecord = reader.getNext()) != null) { - if (messageRecord.isOutgoing()) + if ((isOwnNumber && messageRecord.isOutgoing()) || + (!isOwnNumber && messageRecord.getIndividualRecipient().getAddress().toString().equals(serializedAuthor))) { return messageRecord; } } } - Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null."); - return null; - } - - public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) { - // Early exit if the author is not us - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); - if (!isOwnNumber) { - Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null."); - return null; - } - - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - if (messageRecord.isOutgoing()) { return messageRecord; } - } - } - } - Log.i(TAG, "Could not find last sent message from us in given thread - returning null."); return null; } @@ -200,7 +177,7 @@ public MessageId getLastSentMessageID(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND NOT " + MmsSmsColumns.IS_DELETED; - try (final Cursor cursor = queryTables(PROJECTION, selection, order, null)) { + try (final Cursor cursor = queryTables(PROJECTION, selection, true, null, order, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -214,66 +191,8 @@ public MessageId getLastSentMessageID(long threadId) { return null; } - public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { - return getMessageFor(timestamp, author.toString()); - } - - public long getPreviousPage(long threadId, long fromTime, int limit) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" ASC"; - String selection = MmsSmsColumns.THREAD_ID+" = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+fromTime; - String limitStr = ""+limit; - long sent = -1; - Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); - if (cursor == null) return sent; - Reader reader = readerFor(cursor); - if (!cursor.move(limit)) { - cursor.moveToLast(); - } - MessageRecord record = reader.getCurrent(); - sent = record.getDateSent(); - reader.close(); - return sent; - } - - public Cursor getConversationPage(long threadId, long fromTime, long toTime, int limit) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" <= " + fromTime; - String limitStr = null; - if (toTime != -1L) { - selection += " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+toTime; - } else { - limitStr = ""+limit; - } - - return queryTables(PROJECTION, selection, order, limitStr); - } - - public boolean hasNextPage(long threadId, long toTime) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" < " + toTime; // check if there's at least one message before the `toTime` - Cursor cursor = queryTables(PROJECTION, selection, order, null); - boolean hasNext = false; - if (cursor != null) { - hasNext = cursor.getCount() > 0; - cursor.close(); - } - return hasNext; - } - - public boolean hasPreviousPage(long threadId, long fromTime) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > " + fromTime; // check if there's at least one message after the `fromTime` - Cursor cursor = queryTables(PROJECTION, selection, order, null); - boolean hasNext = false; - if (cursor != null) { - hasNext = cursor.getCount() > 0; - cursor.close(); - } - return hasNext; + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, Address author) { + return getMessageFor(threadId, timestamp, author.toString()); } public Cursor getConversation(long threadId, boolean reverse, long offset, long limit) { @@ -281,7 +200,7 @@ public Cursor getConversation(long threadId, boolean reverse, long offset, long String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; - Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); + Cursor cursor = queryTables(PROJECTION, selection, true, null, order, limitStr); return cursor; } @@ -293,7 +212,7 @@ public Cursor getConversationSnippet(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - return queryTables(PROJECTION, selection, order, null); + return queryTables(PROJECTION, selection, true, null, order, null); } public List getRecentChatMemberAddresses(long threadId, int limit) { @@ -302,7 +221,7 @@ public List getRecentChatMemberAddresses(long threadId, int limit) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = String.valueOf(limit); - try (Cursor cursor = queryTables(projection, selection, order, limitStr)) { + try (Cursor cursor = queryTables(projection, selection, true, null, order, limitStr)) { List addresses = new ArrayList<>(); while (cursor != null && cursor.moveToNext()) { String address = cursor.getString(0); @@ -339,7 +258,7 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, Set identifiedMessages = new HashSet(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -355,7 +274,7 @@ public List> getAllMessageRecordsBefore(long threadI List> identifiedMessages = new ArrayList<>(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -374,7 +293,7 @@ public List> getAllMessagesWithHash(long threadId) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; List> identifiedMessages = new ArrayList<>(); - try (Cursor cursor = queryTables(PROJECTION, selection, null, null); + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null); MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord record; @@ -388,15 +307,40 @@ public List> getAllMessagesWithHash(long threadId) { return identifiedMessages; } + /** + * @param includeReactions Whether to query reactions as well. + */ @Nullable - public MessageRecord getLastMessage(long threadId) { + public MessageRecord getLastMessage(long threadId, boolean includeReactions, boolean getQuote) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; // make sure the last message isn't marked as deleted String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; - try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - return readerFor(cursor).getNext(); + try (Cursor cursor = queryTables(PROJECTION, selection, includeReactions, null, order, "1")) { + return readerFor(cursor, getQuote).getNext(); + } + } + + /** + * Get the maximum timestamp in a thread up to (and including) the message with the given ID. + * Useful for determining the last read timestamp in a thread. + *

+ * This method will also consider the reactions associated with messages in the thread. + * If a reaction has a timestamp greater than the message timestamp, it will be taken into account. + * + * @param messageId The message ID up to which to search. + * @return A pair of maximum timestamp in mills and thread ID, or null if no messages are found. + */ + @Nullable + public Pair getMaxTimestampInThreadUpTo(@NonNull final MessageId messageId) { + Pair query = MmsSmsDatabaseSQLKt.buildMaxTimestampInThreadUpToQuery(messageId); + try (Cursor cursor = getReadableDatabase().rawQuery(query.getFirst(), query.getSecond())) { + if (cursor != null && cursor.moveToFirst()) { + return new Pair<>(cursor.getLong(0), cursor.getLong(1)); + } else { + return null; + } } } @@ -413,7 +357,7 @@ public Cursor getUnreadIncomingForNotifications(int maxRows) { String selection = "(" + READ + " = 0 AND " + NOTIFIED + " = 0 AND NOT (" + outgoing + "))"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, selection, order, limitStr); + return queryTables(PROJECTION, selection, true, null, order, limitStr); } public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { @@ -423,14 +367,12 @@ public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { " FROM " + ThreadDatabase.TABLE_NAME + " WHERE " + ThreadDatabase.ID + " = " + MmsSmsColumns.THREAD_ID; - String selection = - "(" + outgoing + ")" + - " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " IS NOT NULL" + - " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " > (" + lastSeenQuery + ")"; + String reactionSelection = ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " IS NOT NULL" + + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " > (" + lastSeenQuery + ")"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, selection, order, limitStr); + return queryTables(PROJECTION, outgoing, true, reactionSelection, order, limitStr); } public Set

getAllReferencedAddresses() { @@ -439,7 +381,7 @@ public Set
getAllReferencedAddresses() { " AND " + MmsSmsColumns.ADDRESS + " != ''"; Set
out = new HashSet<>(); - try (Cursor cursor = queryTables(projection, selection, null, null)) { + try (Cursor cursor = queryTables(projection, selection, true, null, null, null)) { while (cursor != null && cursor.moveToNext()) { String serialized = cursor.getString(0); try { @@ -467,7 +409,7 @@ private String buildOutgoingTypesList() { public int getUnreadCount(long threadId) { String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - Cursor cursor = queryTables(PROJECTION, selection, null, null); + Cursor cursor = queryTables(new String[] { ID }, selection, true, null, null, null); try { return cursor != null ? cursor.getCount() : 0; @@ -510,7 +452,7 @@ public int getMessagePositionInConversation(long threadId, long sentTimestamp, @ String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { + try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, true, null, order, null)) { String serializedAddress = address.toString(); boolean isOwnNumber = Util.isOwnNumber(context, address.toString()); @@ -655,243 +597,21 @@ public static void migrateLegacyCommunityAddresses2(final SQLiteDatabase db) { migrateLegacyCommunityAddresses2(db, MmsDatabase.TABLE_NAME); } - private Cursor queryTables(String[] projection, String selection, String order, String limit) { - String reactionsColumn = "json_group_array(json_object(" + - "'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " + - "'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " + - "'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " + - "'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " + - "'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " + - "'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " + - "'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " + - "'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " + - "'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " + - "'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED + - ")) AS " + ReactionDatabase.REACTION_JSON_ALIAS; - String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, - MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, - MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID, - "'MMS::' || " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID - + " || '::' || " + MmsDatabase.DATE_SENT - + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, - "json_group_array(json_object(" + - "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + - "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + - "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + "," + - "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + - "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + - "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + - "'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " + - "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + - "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + - "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", " + - "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", " + - "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", " + - "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", " + - "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + - "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + - "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + - "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + - "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + - "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + - "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + - "'" + AttachmentDatabase.AUDIO_DURATION + "', ifnull(" + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", -1), " + - "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + - ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - reactionsColumn, - SmsDatabase.BODY, - MmsDatabase.MESSAGE_CONTENT, - READ, MmsSmsColumns.THREAD_ID, - SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, - MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, - MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, - MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, - MmsSmsColumns.MISMATCHED_IDENTITIES, - MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - NOTIFIED, - MmsDatabase.NETWORK_FAILURE, TRANSPORT, - MmsDatabase.QUOTE_ID, - MmsDatabase.QUOTE_AUTHOR, - MmsDatabase.QUOTE_BODY, - MmsDatabase.QUOTE_MISSING, - MmsDatabase.QUOTE_ATTACHMENT, - MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION, - "mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, - }; - - String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, - SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, - MmsSmsColumns.ID, - "'SMS::' || " + MmsSmsColumns.ID - + " || '::' || " + SmsDatabase.DATE_SENT - + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, - "NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - reactionsColumn, - SmsDatabase.BODY, - MmsSmsColumns.MESSAGE_CONTENT, - READ, MmsSmsColumns.THREAD_ID, - SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, - MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, - MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, - MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, - MmsSmsColumns.MISMATCHED_IDENTITIES, - MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - NOTIFIED, - MmsDatabase.NETWORK_FAILURE, TRANSPORT, - MmsDatabase.QUOTE_ID, - MmsDatabase.QUOTE_AUTHOR, - MmsDatabase.QUOTE_BODY, - MmsDatabase.QUOTE_MISSING, - MmsDatabase.QUOTE_ATTACHMENT, - MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION, - "sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, - }; - - SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); - SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); - - mmsQueryBuilder.setDistinct(true); - smsQueryBuilder.setDistinct(true); - - smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME + - " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0" + - " LEFT OUTER JOIN " + LokiMessageDatabase.smsHashTable + " AS sms_hash" + - " ON sms_hash.message_id = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID); - mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + - " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + - " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + - " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1" + - " LEFT OUTER JOIN " + LokiMessageDatabase.mmsHashTable + " AS mms_hash" + - " ON mms_hash.message_id = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); - - - Set mmsColumnsPresent = new HashSet<>(); - mmsColumnsPresent.add(MmsSmsColumns.ID); - mmsColumnsPresent.add(READ); - mmsColumnsPresent.add(MmsSmsColumns.THREAD_ID); - mmsColumnsPresent.add(MmsSmsColumns.MESSAGE_CONTENT); - mmsColumnsPresent.add(MmsSmsColumns.BODY); - mmsColumnsPresent.add(MmsSmsColumns.ADDRESS); - mmsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); - mmsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); - mmsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); - mmsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); - mmsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); - mmsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); - mmsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_TYPE); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_BOX); - mmsColumnsPresent.add(MmsDatabase.DATE_SENT); - mmsColumnsPresent.add(MmsDatabase.DATE_RECEIVED); - mmsColumnsPresent.add(MmsDatabase.PART_COUNT); - mmsColumnsPresent.add(MmsDatabase.CONTENT_LOCATION); - mmsColumnsPresent.add(MmsDatabase.TRANSACTION_ID); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_SIZE); - mmsColumnsPresent.add(MmsDatabase.EXPIRY); - mmsColumnsPresent.add(NOTIFIED); - mmsColumnsPresent.add(MmsDatabase.STATUS); - mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); - mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); - mmsColumnsPresent.add("mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); - - mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); - mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); - mmsColumnsPresent.add(AttachmentDatabase.MMS_ID); - mmsColumnsPresent.add(AttachmentDatabase.SIZE); - mmsColumnsPresent.add(AttachmentDatabase.FILE_NAME); - mmsColumnsPresent.add(AttachmentDatabase.DATA); - mmsColumnsPresent.add(AttachmentDatabase.THUMBNAIL); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_TYPE); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_LOCATION); - mmsColumnsPresent.add(AttachmentDatabase.DIGEST); - mmsColumnsPresent.add(AttachmentDatabase.FAST_PREFLIGHT_ID); - mmsColumnsPresent.add(AttachmentDatabase.VOICE_NOTE); - mmsColumnsPresent.add(AttachmentDatabase.WIDTH); - mmsColumnsPresent.add(AttachmentDatabase.HEIGHT); - mmsColumnsPresent.add(AttachmentDatabase.QUOTE); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_ID); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_KEY); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_ID); - mmsColumnsPresent.add(AttachmentDatabase.CAPTION); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_DISPOSITION); - mmsColumnsPresent.add(AttachmentDatabase.NAME); - mmsColumnsPresent.add(AttachmentDatabase.TRANSFER_STATE); - mmsColumnsPresent.add(AttachmentDatabase.ATTACHMENT_JSON_ALIAS); - mmsColumnsPresent.add(MmsDatabase.QUOTE_ID); - mmsColumnsPresent.add(MmsDatabase.QUOTE_AUTHOR); - mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY); - mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); - mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); - mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); - mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); - mmsColumnsPresent.add(ReactionDatabase.MESSAGE_ID); - mmsColumnsPresent.add(ReactionDatabase.IS_MMS); - mmsColumnsPresent.add(ReactionDatabase.AUTHOR_ID); - mmsColumnsPresent.add(ReactionDatabase.EMOJI); - mmsColumnsPresent.add(ReactionDatabase.SERVER_ID); - mmsColumnsPresent.add(ReactionDatabase.COUNT); - mmsColumnsPresent.add(ReactionDatabase.SORT_ID); - mmsColumnsPresent.add(ReactionDatabase.DATE_SENT); - mmsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); - mmsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); - - Set smsColumnsPresent = new HashSet<>(); - smsColumnsPresent.add(MmsSmsColumns.ID); - smsColumnsPresent.add(MmsSmsColumns.BODY); - smsColumnsPresent.add(MmsSmsColumns.ADDRESS); - smsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); - smsColumnsPresent.add(READ); - smsColumnsPresent.add(MmsSmsColumns.THREAD_ID); - smsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); - smsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); - smsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); - smsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); - smsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); - smsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); - smsColumnsPresent.add(NOTIFIED); - smsColumnsPresent.add(SmsDatabase.TYPE); - smsColumnsPresent.add(SmsDatabase.SUBJECT); - smsColumnsPresent.add(SmsDatabase.DATE_SENT); - smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); - smsColumnsPresent.add(SmsDatabase.STATUS); - smsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); - smsColumnsPresent.add(ReactionDatabase.ROW_ID); - smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID); - smsColumnsPresent.add(ReactionDatabase.IS_MMS); - smsColumnsPresent.add(ReactionDatabase.AUTHOR_ID); - smsColumnsPresent.add(ReactionDatabase.EMOJI); - smsColumnsPresent.add(ReactionDatabase.SERVER_ID); - smsColumnsPresent.add(ReactionDatabase.COUNT); - smsColumnsPresent.add(ReactionDatabase.SORT_ID); - smsColumnsPresent.add(ReactionDatabase.DATE_SENT); - smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); - smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); - smsColumnsPresent.add("sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); - - @SuppressWarnings("deprecation") - String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); - @SuppressWarnings("deprecation") - String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 5, SMS_TRANSPORT, selection, null, SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, null); - - SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder(); - String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit); - - SQLiteQueryBuilder outerQueryBuilder = new SQLiteQueryBuilder(); - outerQueryBuilder.setTables("(" + unionQuery + ")"); - - @SuppressWarnings("deprecation") - String query = outerQueryBuilder.buildQuery(projection, null, null, null, null, null, null); - + private Cursor queryTables( + @NonNull String[] projection, + @Nullable String selection, + boolean includeReactions, + @Nullable String additionalReactionSelection, + @Nullable String order, + @Nullable String limit) { SQLiteDatabase db = getReadableDatabase(); - return db.rawQuery(query, null); + String query = MmsSmsDatabaseSQLKt.buildMmsSmsCombinedQuery(projection, + selection, + includeReactions, + additionalReactionSelection, + order, + limit); + return db.rawQuery(query, null); } public Reader readerFor(@NonNull Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt new file mode 100644 index 0000000000..59ab96dc09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt @@ -0,0 +1,294 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.database.model.MessageId + +/** + * Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to + * use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need + * to have the same projection so we'll also do some aliasing on them. This can be illustrated as: + * + * For each message, we will perform sub-query to reaction/attachment database to query the relevant + * data. We try not to use JOIN as they screw up performance by impacting the index selection. + * + * ```sqlite + * SELECT sms_fields, + * (query reaction table) AS reactions, + * (query hash table) AS server_hash + * FROM sms + * + * UNION ALL + * + * SELECT + * mms_fields, + * (query reaction table) AS reactions, + * (query attachment table) AS attachments, + * (query hash table) AS server_hash + * FROM mms + * ``` + */ +fun buildMmsSmsCombinedQuery( + projections: Array, + selection: String?, + includeReactions: Boolean, + reactionSelection: String?, + order: String?, + limit: String? +): String { + // The query parts that fetch all reactions for a given message, and group them into a JSON array + val reactionsQueryParts = """ + SELECT json_group_array( + json_object( + '${ReactionDatabase.ROW_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.ROW_ID}, + '${ReactionDatabase.MESSAGE_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID}, + '${ReactionDatabase.IS_MMS}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS}, + '${ReactionDatabase.AUTHOR_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.AUTHOR_ID}, + '${ReactionDatabase.EMOJI}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.EMOJI}, + '${ReactionDatabase.SERVER_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SERVER_ID}, + '${ReactionDatabase.COUNT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.COUNT}, + '${ReactionDatabase.SORT_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SORT_ID}, + '${ReactionDatabase.DATE_SENT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_SENT}, + '${ReactionDatabase.DATE_RECEIVED}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_RECEIVED} + ) + ) + FROM ${ReactionDatabase.TABLE_NAME} + """ + + // Subquery to grab sms' server hash + val smsHashQuery = """ + SELECT server_hash + FROM ${LokiMessageDatabase.smsHashTable} sms_hash + WHERE sms_hash.message_id = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Custom where statement for reactions if provided + val additionalReactionSelection = reactionSelection?.let { " AND ($it)" }.orEmpty() + + // If reactions are not requested, we just return an empty JSON array + val smsReactionQuery = if (includeReactions) { + """($reactionsQueryParts + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND NOT ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + val whereStatement = selection?.let { "WHERE $it" }.orEmpty() + + // The main query for SMS messages + val smsQuery = """ + SELECT + ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${SmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsSmsColumns.ID}, + 'SMS::' || ${MmsSmsColumns.ID} || '::' || ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + NULL AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $smsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${SmsDatabase.BODY}, + NULL AS ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + ${SmsDatabase.TYPE}, + ${SmsDatabase.ADDRESS}, + ${SmsDatabase.ADDRESS_DEVICE_ID}, + ${SmsDatabase.SUBJECT}, + NULL AS ${MmsDatabase.MESSAGE_TYPE}, + NULL AS ${MmsDatabase.MESSAGE_BOX}, + ${SmsDatabase.STATUS}, + NULL AS ${MmsDatabase.PART_COUNT}, + NULL AS ${MmsDatabase.CONTENT_LOCATION}, + NULL AS ${MmsDatabase.TRANSACTION_ID}, + NULL AS ${MmsDatabase.MESSAGE_SIZE}, + NULL AS ${MmsDatabase.EXPIRY}, + NULL AS ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.MISMATCHED_IDENTITIES}, + ${MmsSmsColumns.SUBSCRIPTION_ID}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + NULL AS ${MmsDatabase.NETWORK_FAILURE}, + '${MmsSmsDatabase.SMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + NULL AS ${MmsDatabase.QUOTE_ID}, + NULL AS ${MmsDatabase.QUOTE_AUTHOR}, + NULL AS ${MmsDatabase.QUOTE_BODY}, + NULL AS ${MmsDatabase.QUOTE_MISSING}, + NULL AS ${MmsDatabase.QUOTE_ATTACHMENT}, + NULL AS ${MmsDatabase.SHARED_CONTACTS}, + NULL AS ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($smsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + FROM ${SmsDatabase.TABLE_NAME} + $whereStatement + """ + + // Subquery to grab mms' server hash + val mmsHashQuery = """ + SELECT server_hash + FROM ${LokiMessageDatabase.mmsHashTable} mms_hash + WHERE mms_hash.message_id = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // The subquery that fetches all attachments for a given MMS message, and group them into a JSON array + val attachmentQuery = """ + SELECT json_group_array( + json_object( + '${AttachmentDatabase.ROW_ID}', a.${AttachmentDatabase.ROW_ID}, + '${AttachmentDatabase.UNIQUE_ID}', a.${AttachmentDatabase.UNIQUE_ID}, + '${AttachmentDatabase.MMS_ID}', a.${AttachmentDatabase.MMS_ID}, + '${AttachmentDatabase.SIZE}', a.${AttachmentDatabase.SIZE}, + '${AttachmentDatabase.FILE_NAME}', a.${AttachmentDatabase.FILE_NAME}, + '${AttachmentDatabase.DATA}', a.${AttachmentDatabase.DATA}, + '${AttachmentDatabase.THUMBNAIL}', a.${AttachmentDatabase.THUMBNAIL}, + '${AttachmentDatabase.CONTENT_TYPE}', a.${AttachmentDatabase.CONTENT_TYPE}, + '${AttachmentDatabase.CONTENT_LOCATION}', a.${AttachmentDatabase.CONTENT_LOCATION}, + '${AttachmentDatabase.FAST_PREFLIGHT_ID}', a.${AttachmentDatabase.FAST_PREFLIGHT_ID}, + '${AttachmentDatabase.VOICE_NOTE}', a.${AttachmentDatabase.VOICE_NOTE}, + '${AttachmentDatabase.WIDTH}', a.${AttachmentDatabase.WIDTH}, + '${AttachmentDatabase.HEIGHT}', a.${AttachmentDatabase.HEIGHT}, + '${AttachmentDatabase.QUOTE}', a.${AttachmentDatabase.QUOTE}, + '${AttachmentDatabase.CONTENT_DISPOSITION}', a.${AttachmentDatabase.CONTENT_DISPOSITION}, + '${AttachmentDatabase.NAME}', a.${AttachmentDatabase.NAME}, + '${AttachmentDatabase.TRANSFER_STATE}', a.${AttachmentDatabase.TRANSFER_STATE}, + '${AttachmentDatabase.CAPTION}', a.${AttachmentDatabase.CAPTION}, + '${AttachmentDatabase.STICKER_PACK_ID}', a.${AttachmentDatabase.STICKER_PACK_ID}, + '${AttachmentDatabase.STICKER_PACK_KEY}', a.${AttachmentDatabase.STICKER_PACK_KEY}, + '${AttachmentDatabase.AUDIO_DURATION}', ifnull(a.${AttachmentDatabase.AUDIO_DURATION}, -1), + '${AttachmentDatabase.STICKER_ID}', a.${AttachmentDatabase.STICKER_ID} + ) + ) + FROM ${AttachmentDatabase.TABLE_NAME} AS a + WHERE a.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Custom where statement for reactions if provided + val mmsReactionQuery = if (includeReactions) { + """($reactionsQueryParts + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + // The main query for MMS messages + val mmsQuery = """ + SELECT + ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${MmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} AS ${MmsSmsColumns.ID}, + 'MMS::' || ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} || '::' || ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + ($attachmentQuery) AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $mmsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${MmsSmsColumns.BODY}, + ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + NULL AS ${SmsDatabase.TYPE}, + ${MmsSmsColumns.ADDRESS}, + ${MmsSmsColumns.ADDRESS_DEVICE_ID}, + NULL AS ${SmsDatabase.SUBJECT}, + ${MmsDatabase.MESSAGE_TYPE}, + ${MmsDatabase.MESSAGE_BOX}, + NULL AS ${SmsDatabase.STATUS}, + ${MmsDatabase.PART_COUNT}, + ${MmsDatabase.CONTENT_LOCATION}, + ${MmsDatabase.TRANSACTION_ID}, + ${MmsDatabase.MESSAGE_SIZE}, + ${MmsDatabase.EXPIRY}, + ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.MISMATCHED_IDENTITIES}, + ${MmsSmsColumns.SUBSCRIPTION_ID}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + ${MmsDatabase.NETWORK_FAILURE}, + '${MmsSmsDatabase.MMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + ${MmsDatabase.QUOTE_ID}, + ${MmsDatabase.QUOTE_AUTHOR}, + ${MmsDatabase.QUOTE_BODY}, + ${MmsDatabase.QUOTE_MISSING}, + ${MmsDatabase.QUOTE_ATTACHMENT}, + ${MmsDatabase.SHARED_CONTACTS}, + ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($mmsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + FROM ${MmsDatabase.TABLE_NAME} + $whereStatement + """ + + val orderStatement = order?.let { "ORDER BY $it" }.orEmpty() + val limitStatement = limit?.let { "LIMIT $it" }.orEmpty() + + return """ + WITH combined AS ( + $smsQuery + UNION ALL + $mmsQuery + ) + + SELECT ${projections.joinToString(", ")} + FROM combined + $orderStatement + $limitStatement + """ +} + +/** + * Build a query to get the maximum timestamp (date sent) in a thread up to and including + * the timestamp of the given message ID. + * + * This query will also look at reactions associated with messages in the thread + * to ensure that if there are reactions with later timestamps, they are considered + * as well. + * + * @return A pair containing the SQL query string and an array of parameters to bind. + * The query will return at most one row of "maxTimestamp", "threadId". + */ +fun buildMaxTimestampInThreadUpToQuery(id: MessageId): Pair> { + val msgTable = if (id.mms) MmsDatabase.TABLE_NAME else SmsDatabase.TABLE_NAME + val dateSentColumn = if (id.mms) MmsDatabase.DATE_SENT else SmsDatabase.DATE_SENT + val threadIdColumn = if (id.mms) MmsSmsColumns.THREAD_ID else SmsDatabase.THREAD_ID + + // The query below does this: + // 1. Query the given message, find out its thread id and its date sent + // 2. Find all the messages in this thread before this messages (using result from step 1) + // 3. With this message + earlier messages, grab all the reactions associated with them + // 4. Look at the max date among the reactions returned from step 3 + // 5. Return the max between this message's date and the max reaction date + return """ + SELECT + MAX( + mainMessage.$dateSentColumn, + IFNULL( + ( + SELECT MAX(r.${ReactionDatabase.DATE_SENT}) + FROM ${ReactionDatabase.TABLE_NAME} r + INDEXED BY reaction_message_id_is_mms_index + WHERE (r.${ReactionDatabase.MESSAGE_ID}, r.${ReactionDatabase.IS_MMS}) IN ( + SELECT s.${MmsSmsColumns.ID}, FALSE + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = mainMessage.${threadIdColumn} AND + s.${SmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + + UNION ALL + + SELECT m.${MmsSmsColumns.ID}, TRUE + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = mainMessage.${threadIdColumn} AND + m.${MmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + ) + ), + 0 + ) + ) AS maxTimestamp, + mainMessage.$threadIdColumn AS threadId + FROM $msgTable mainMessage + WHERE mainMessage.${MmsSmsColumns.ID} = ? + """ to arrayOf(id.id) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PushRegistrationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/PushRegistrationDatabase.kt new file mode 100644 index 0000000000..7a5a0c05f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PushRegistrationDatabase.kt @@ -0,0 +1,282 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.sqlite.db.transaction +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.asSequence +import java.time.Instant +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class PushRegistrationDatabase @Inject constructor( + @ApplicationContext context: Context, + helper: Provider, + private val json: Json, +) : Database(context, helper) { + + + @Serializable + data class Registration(val accountId: String, val input: Input) + + /** + * Ensure that the provided registrations exist in the database. If input changes for an existing + * registration, reset its state to NONE. Any registrations not in the provided list will be + * marked as pending unregistration. + * + * @return The number of database rows that were changed. + */ + fun ensureRegistrations(registrations: Collection): Int { + val registrationsAsText = json.encodeToString(registrations) + + // It's important to specify the base RegistrationState so that the discriminator is correct + val pendingRegisterAsText = json.encodeToString(RegistrationState.PendingRegister) + val pendingUnregisterAsText = json.encodeToString(RegistrationState.PendingUnregister) + + return writableDatabase.transaction { + var numChanges = 0 + + if (registrations.isNotEmpty()) { + // Insert the provided registrations with PendingRegister state + // If they already exist with a PendingUnregister state, flip them back to PendingRegister, + // otherwise keep their existing state. + compileStatement( + """ + INSERT INTO push_registration_state (account_id, input, state) + SELECT + value->>'$.accountId', + value->>'$.input', + :pending_register_state + FROM json_each(:registrations) + WHERE TRUE + ON CONFLICT DO UPDATE + SET state = :pending_register_state + WHERE state_type = '$TYPE_PENDING_UNREGISTER' + """ + ).use { stmt -> + stmt.bindString(1, pendingRegisterAsText) + stmt.bindString(2, registrationsAsText) + numChanges += stmt.executeUpdateDelete() + } + } + + // Mark all other registrations that are registered or error as PendingUnregister to be cleaned up + compileStatement(""" + UPDATE push_registration_state + SET state = ? + WHERE (account_id, input) NOT IN (SELECT value->>'$.accountId', value->>'$.input' FROM json_each(?)) + AND state_type IN ('$TYPE_REGISTERED', '$TYPE_ERROR') + """).use { stmt -> + stmt.bindString(1, pendingUnregisterAsText) + stmt.bindString(2, registrationsAsText) + numChanges += stmt.executeUpdateDelete() + } + + // Delete no longer desired registrations that didn't start, or ended up in a permanent error + // Note: the changes here do not count towards numChanges since they won't affect + // the scheduling + compileStatement(""" + DELETE FROM push_registration_state + WHERE state_type IN ('$TYPE_PERMANENT_ERROR', '$TYPE_PENDING_REGISTER') + AND (account_id, input) NOT IN (SELECT value->>'$.accountId', value->>'$.input' FROM json_each(?)) + """).use { stmt -> + stmt.bindString(1, registrationsAsText) + stmt.execute() + } + + numChanges + } + } + + fun updateRegistrations(registrationWithStates: Collection) { + writableDatabase.compileStatement( + """ + UPDATE push_registration_state + SET state = ? + WHERE account_id = ? AND input = ? + """ + ).use { stmt -> + for (r in registrationWithStates) { + stmt.clearBindings() + stmt.bindString(1, json.encodeToString(r.state)) + stmt.bindString(2, r.accountId) + stmt.bindString(3, json.encodeToString(r.input)) + stmt.execute() + } + } + } + + data class PendingRegistrationWork( + val register: List, + val unregister: List, + ) + + fun getPendingRegistrationWork(now: Instant = Instant.now(), limit: Int): PendingRegistrationWork { + // This query needs to consider two type of data: + // - Registrations that need to be registered (due REGISTER, due ERROR or NONE) + // - Registrations that need to be unregistered (PENDING_UNREGISTER) + // The query does not directly map to these two groups, so we partition the results in code. + return readableDatabase.rawQuery( + """ + SELECT account_id, input, state, CAST(state->>'$.due' AS INTEGER) AS due_time + FROM push_registration_state + WHERE state_type IN ('$TYPE_ERROR', '$TYPE_REGISTERED') + AND CAST(state->>'$.due' AS INTEGER) <= ? + + UNION ALL + + SELECT account_id, input, state, 0 AS due_time + FROM push_registration_state + WHERE state_type IN ('$TYPE_PENDING_REGISTER', '$TYPE_PENDING_UNREGISTER') + + ORDER BY due_time ASC + LIMIT ? + """, now.toEpochMilli(), limit + ).use { cursor -> + val (unregister, register) = cursor.asSequence() + .map { + RegistrationWithState( + accountId = cursor.getString(0), + input = json.decodeFromString(cursor.getString(1)), + state = json.decodeFromString(cursor.getString(2)), + ) + } + .partition { it.state is RegistrationState.PendingUnregister } + + PendingRegistrationWork(register, unregister) + } + } + + fun removeRegistrations(registrations: Collection) { + if (registrations.isEmpty()) return + + writableDatabase.rawExecSQL( + """ + DELETE FROM push_registration_state + WHERE (account_id, input) IN (SELECT value->>'$.accountId', value->>'$.input' FROM json_each(?)) + """, json.encodeToString(registrations) + ) + } + + /** + * Get the next due time among all registrations. Null if there are no pending registrations. + * + * Note that the due time can be in the past or now, meaning there are som registrations + * that must be processed immediately. + */ + fun getNextProcessTime(now: Instant = Instant.now()): Instant? { + // The NONE state means we should process immediately, so we'll look them up first + readableDatabase.rawQuery( + """ + SELECT 1 FROM push_registration_state + WHERE state_type IN ('$TYPE_PENDING_REGISTER', '$TYPE_PENDING_UNREGISTER') + """ + ).use { cursor -> + if (cursor.moveToNext()) { + return now + } + } + + // Otherwise, find the minimum due time among ERROR and REGISTERED states + readableDatabase.rawQuery( + """ + SELECT MIN(CAST(state->>'$.due' AS INTEGER)) + FROM push_registration_state + WHERE state_type IN ('$TYPE_ERROR', '$TYPE_REGISTERED') + """, + ).use { cursor -> + if (cursor.moveToFirst()) { + val dueMillis = cursor.getLong(0) + if (!cursor.isNull(0)) { + return Instant.ofEpochMilli(dueMillis) + } + } + } + + return null + } + + @Serializable + data class RegistrationWithState( + val accountId: String, + val input: Input, + val state: RegistrationState + ) + + /** + * The registration state that is saved in the db. + * + * Please note that any changes to this class must consider the backward compatibility + * to the existing data in the database. + */ + @OptIn(ExperimentalSerializationApi::class) + @Serializable + @JsonClassDiscriminator(STATE_TYPE_DISCRIMINATOR) + sealed interface RegistrationState { + @Serializable + @SerialName(TYPE_PENDING_REGISTER) + data object PendingRegister : RegistrationState + + @Serializable + @SerialName(TYPE_REGISTERED) + data class Registered( + @Serializable(with = InstantAsMillisSerializer::class) + val due: Instant + ) : RegistrationState + + @Serializable + @SerialName(TYPE_ERROR) + data class Error( + @Serializable(with = InstantAsMillisSerializer::class) + val due: Instant, + val numRetried: Int, + ) : RegistrationState + + @Serializable + @SerialName(TYPE_PERMANENT_ERROR) + data object PermanentError : RegistrationState + + @Serializable + @SerialName(TYPE_PENDING_UNREGISTER) + data object PendingUnregister : RegistrationState + } + + /** + * The input required to perform a push registration. + */ + @Serializable + data class Input( + val pushToken: String + ) + + companion object { + private const val STATE_TYPE_DISCRIMINATOR = "type" + + private const val TYPE_PENDING_REGISTER = "PENDING_REGISTER" + private const val TYPE_REGISTERED = "REGISTERED" + private const val TYPE_ERROR = "ERROR" + private const val TYPE_PERMANENT_ERROR = "PERMANENT_ERROR" + private const val TYPE_PENDING_UNREGISTER = "PENDING_UNREGISTER" + + fun createTableStatements() = arrayOf( + """ + CREATE TABLE push_registration_state( + account_id TEXT NOT NULL, + input TEXT NOT NULL, + state TEXT NOT NULL, + state_type TEXT GENERATED ALWAYS AS (state->>'$.$STATE_TYPE_DISCRIMINATOR') VIRTUAL, + PRIMARY KEY (account_id, input) + ) WITHOUT ROWID""", + "CREATE INDEX idx_push_state_type ON push_registration_state(state_type)", + "CREATE INDEX idx_push_due ON push_registration_state(CAST(state->>'$.due' AS INTEGER))" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index b0c91e538f..38c1d6eb34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -155,6 +155,8 @@ public class SmsDatabase extends MessagingDatabase { "DROP TABLE " + TEMP_TABLE_NAME }; + public static String ADD_LAST_MESSAGE_INDEX = "CREATE INDEX sms_thread_id_date_sent_index ON " + TABLE_NAME + " (" + THREAD_ID + "," + DATE_SENT + ")"; + public static final String ADD_IS_DELETED_COLUMN = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + IS_DELETED_COLUMN_DEF; public static final String ADD_IS_GROUP_UPDATE_COLUMN = "ALTER TABLE " + TABLE_NAME +" ADD COLUMN " + IS_GROUP_UPDATE +" BOOL GENERATED ALWAYS AS (" + TYPE +" & " + GROUP_UPDATE_MESSAGE_BIT +" != 0) VIRTUAL"; @@ -437,7 +439,7 @@ private List setMessagesRead(String where, String[] arguments public void updateSentTimestamp(long messageId, long newTimestamp) { SQLiteDatabase db = getWritableDatabase(); - db.rawQuery("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + + db.rawExecSQL("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + "WHERE " + ID + " = ?", newTimestamp, messageId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 3e6498360f..564a2a8234 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.MutableConversationVolatileConfig +import network.loki.messenger.libsession_util.ReadableUserGroupsConfig import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.Conversation @@ -51,6 +52,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.isCommunity +import org.session.libsession.utilities.isCommunityInbox import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.upsertContact @@ -74,9 +76,11 @@ import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.time.Instant +import java.time.ZoneId import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton +import kotlin.math.max import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember private const val TAG = "Storage" @@ -192,67 +196,100 @@ open class Storage @Inject constructor( return messages.map { it.second } // return the message hashes } - override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean, updateNotification: Boolean) { val threadDb = threadDatabase - getRecipientForThread(threadId)?.let { recipient -> - val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() - // don't set the last read in the volatile if we didn't set it in the DB - if (!threadDb.markAllAsRead(threadId, lastSeenTime, force) && !force) return + val threadAddress = threadDb.getRecipientForThreadId(threadId) ?: return + // don't set the last read in the volatile if we didn't set it in the DB + if (!threadDb.markAllAsRead(threadId, lastSeenTime, force, updateNotification) && !force) return - // don't process configs for inbox recipients - if (recipient.isCommunityInboxRecipient) return + // don't process configs for inbox recipients + if (threadAddress.isCommunityInbox) return - configFactory.withMutableUserConfigs { configs -> - val config = configs.convoInfoVolatile - val convo = getConvo(recipient, config) ?: return@withMutableUserConfigs - convo.lastRead = lastSeenTime + val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() - if(convo.unread){ - convo.unread = lastSeenTime < currentLastRead - } + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile + val convo = getConvo( + threadAddress = threadAddress, + config = config, + groupConfig = configs.userGroups + ) ?: return@withMutableUserConfigs + convo.lastRead = lastSeenTime + + if(convo.unread){ + convo.unread = lastSeenTime < currentLastRead + } + + config.set(convo) + } + } - config.set(convo) + override fun markConversationAsReadUpToMessage(messageId: MessageId) { + val maxTimestampMillsAndThreadId = mmsSmsDatabase.getMaxTimestampInThreadUpTo(messageId) + if (maxTimestampMillsAndThreadId != null) { + val threadId = maxTimestampMillsAndThreadId.second + val maxTimestamp = maxTimestampMillsAndThreadId.first + if (getLastSeen(threadId) < maxTimestamp) { + Log.d(TAG, "Marking last seen for thread $threadId as ${Instant.ofEpochMilli(maxTimestamp).atZone( + ZoneId.systemDefault())}") + markConversationAsRead( + threadId = threadId, + lastSeenTime = maxTimestamp, + force = false, + updateNotification = true + ) } } } override fun markConversationAsUnread(threadId: Long) { - getRecipientForThread(threadId)?.let { recipient -> - // don't process configs for inbox recipients - if (recipient.isCommunityInboxRecipient) return + val threadAddress = threadDatabase.getRecipientForThreadId(threadId) ?: return - configFactory.withMutableUserConfigs { configs -> - val config = configs.convoInfoVolatile - val convo = getConvo(recipient, config) ?: return@withMutableUserConfigs + // don't process configs for inbox recipients + if (threadAddress.isCommunityInbox) return - convo.unread = true - config.set(convo) - } + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile + val convo = getConvo( + threadAddress = threadAddress, + config = config, + groupConfig = configs.userGroups + ) ?: return@withMutableUserConfigs + + convo.unread = true + config.set(convo) } } - private fun getConvo(recipient: Recipient, config: MutableConversationVolatileConfig) : Conversation? { - return when (recipient.address) { + private fun getConvo( + threadAddress: Address, + config: MutableConversationVolatileConfig, + groupConfig: ReadableUserGroupsConfig + ) : Conversation? { + return when (threadAddress) { // recipient closed group - is Address.LegacyGroup -> config.getOrConstructLegacyGroup(recipient.address.groupPublicKeyHex) - is Address.Group -> config.getOrConstructClosedGroup(recipient.address.accountId.hexString) + is Address.LegacyGroup -> config.getOrConstructLegacyGroup(threadAddress.groupPublicKeyHex) + is Address.Group -> config.getOrConstructClosedGroup(threadAddress.accountId.hexString) // recipient is open group is Address.Community -> { - val og = recipient.data as? RecipientData.Community ?: return null + val og = groupConfig.getCommunityInfo( + baseUrl = threadAddress.serverUrl, + room = threadAddress.room, + ) ?: return null config.getOrConstructCommunity( - baseUrl = recipient.address.serverUrl, - room = recipient.address.room, - pubKeyHex = og.serverPubKey, + baseUrl = threadAddress.serverUrl, + room = threadAddress.room, + pubKeyHex = og.community.pubKeyHex, ) } is Address.CommunityBlindedId -> { - config.getOrConstructedBlindedOneToOne(recipient.address.blindedId.blindedId.hexString) + config.getOrConstructedBlindedOneToOne(threadAddress.blindedId.blindedId.hexString) } // otherwise recipient is one to one is Address.Standard -> { - config.getOrConstructOneToOne(recipient.address.accountId.hexString) + config.getOrConstructOneToOne(threadAddress.accountId.hexString) } - else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address}") + else -> throw NullPointerException("Weren't expecting to have a convo with address ${threadAddress}") } } @@ -481,10 +518,16 @@ open class Storage @Inject constructor( SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageBy(timestamp: Long, author: String): MessageRecord? { + override fun getMessageBy(threadId: Long, timestamp: Long, author: String): MessageRecord? { val database = mmsSmsDatabase val address = fromSerialized(author) - return database.getMessageFor(timestamp, address) + return database.getMessageFor(threadId, timestamp, address) + } + + @Deprecated("We shouldn't be querying messages by timestamp alone. Use `getMessageBy` when possible ") + override fun getMessageByTimestamp(timestamp: Long, author: String, getQuote: Boolean): MessageRecord? { + val database = mmsSmsDatabase + return database.getMessageByTimestamp(timestamp, author, getQuote) } override fun updateSentTimestamp( @@ -636,7 +679,7 @@ open class Storage @Inject constructor( ) val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { + if (mmsSmsDB.getMessageFor(threadID, sentTimestamp, userPublicKey) != null) { Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") return null } @@ -797,7 +840,7 @@ open class Storage @Inject constructor( val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase // check for conflict here, not returning duplicate in case it's different - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null + if (mmsSmsDB.getMessageFor(threadID, sentTimestamp, userPublicKey) != null) return null val infoMessageID = mmsDB.insertMessageOutbox( infoMessage, threadID, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 9465781ae8..ec0cdbf912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -761,13 +761,15 @@ public boolean isRead(long threadId) { /** * @param threadId * @param lastSeenTime + * @param force + * @param updateNotifications - if true, update the notification state. Set to false if you already came from a notification interaction * @return true if we have set the last seen for the thread, false if there were no messages in the thread */ - public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force) { + public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force, boolean updateNotifications) { if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); MarkReadReceiver.process(context, messages); - messageNotifier.get().updateNotification(context, threadId); + if(updateNotifications) messageNotifier.get().updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } @@ -859,7 +861,7 @@ public ThreadRecord getCurrent() { MessageRecord lastMessage = null; if (count > 0) { - lastMessage = mmsSmsDatabase.get().getLastMessage(threadId); + lastMessage = mmsSmsDatabase.get().getLastMessage(threadId, false, false); } final GroupThreadStatus groupThreadStatus; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index ae56d16c0f..0ab54cbdca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.PushDatabase; +import org.thoughtcrime.securesms.database.PushRegistrationDatabase; import org.thoughtcrime.securesms.database.ReactionDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientSettingsDatabase; @@ -99,9 +100,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV51 = 72; private static final int lokiV52 = 73; private static final int lokiV53 = 74; + private static final int lokiV54 = 75; + private static final int lokiV55 = 76; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV53; + private static final int DATABASE_VERSION = lokiV55; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -258,6 +261,11 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(CommunityDatabase.MIGRATE_CREATE_TABLE); executeStatements(db, CommunityDatabase.Companion.getMIGRATE_DROP_OLD_TABLES()); + + db.execSQL(SmsDatabase.ADD_LAST_MESSAGE_INDEX); + db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); + + executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); } @Override @@ -587,6 +595,15 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { MmsSmsDatabase.migrateLegacyCommunityAddresses2(db); } + if (oldVersion < lokiV54) { + db.execSQL(SmsDatabase.ADD_LAST_MESSAGE_INDEX); + db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); + } + + if (oldVersion < lokiV55) { + executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index e3ed976a0c..2cf5359d18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -243,8 +243,9 @@ fun DebugMenu( selected = null, modifier = modifier, values = uiState.debugProPlans, - onValueSelected = { sendCommand(DebugMenuViewModel.Commands.PurchaseDebugPlan(it)) }, - labeler = { it?.label ?: "Select a plan to buy" } + onValueSelected = { sendCommand(DebugMenuViewModel.Commands.PurchaseDebugPlan(it!!)) }, + labeler = { it?.label ?: "Select a plan to buy" }, + allowSelectingNullValue = false, ) Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) @@ -351,15 +352,6 @@ fun DebugMenu( } ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - DebugSwitchRow( - text = "Force 30sec TTL avatar", - checked = uiState.forceShortTTl, - onCheckedChange = { - sendCommand(DebugMenuViewModel.Commands.ForceShortTTl(it)) - } - ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) Row( verticalAlignment = Alignment.CenterVertically, @@ -471,6 +463,62 @@ fun DebugMenu( } } + DebugCell("Fileserver, avatar & attachment") { + Text("Alternative file server") + + DropDown( + modifier = Modifier.fillMaxWidth(), + selected = uiState.alternativeFileServer, + values = uiState.availableAltFileServers, + onValueSelected = { sendCommand(DebugMenuViewModel.Commands.SelectAltFileServer(it)) }, + labeler = { it?.url?.host ?: "Do not use" }, + allowSelectingNullValue = true, + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + DebugSwitchRow( + text = "Uses deterministic encryption for both avatar and attachment uploads", + checked = uiState.forceDeterministicEncryption, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ToggleDeterministicEncryption) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + DebugSwitchRow( + text = "Debug avatar reupload (shorten interval, and toast messages)", + checked = uiState.debugAvatarReupload, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ToggleDebugAvatarReupload) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Force 30sec TTL avatar", + checked = uiState.forceShortTTl, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceShortTTl(it)) + } + ) + + SlimOutlineButton( + modifier = Modifier.fillMaxWidth(), + text = "Reset Push Token", + ) { + sendCommand(DebugMenuViewModel.Commands.ResetPushToken) + } + + SlimOutlineButton( + modifier = Modifier.fillMaxWidth(), + text = "Clear All Trusted Downloads", + ) { + sendCommand(ClearTrustedDownloads) + } + } + // Group deprecation state DebugCell("Legacy Group Deprecation Overrides") { DropDown( @@ -751,6 +799,8 @@ fun PreviewDebugMenu() { debugSubscriptionStatuses = setOf(DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE), selectedDebugSubscriptionStatus = DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE, debugProPlans = emptyList(), + forceDeterministicEncryption = false, + debugAvatarReupload = true, ), sendCommand = {}, onClose = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 504ce8609c..b88a47d3ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -22,8 +22,10 @@ import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDD import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServer import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -59,6 +61,7 @@ class DebugMenuViewModel @Inject constructor( private val attachmentDatabase: AttachmentDatabase, private val conversationRepository: ConversationRepository, private val databaseInspector: DatabaseInspector, + private val tokenFetcher: TokenFetcher, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" @@ -74,6 +77,7 @@ class DebugMenuViewModel @Inject constructor( hideMessageRequests = textSecurePreferences.hasHiddenMessageRequests(), hideNoteToSelf = configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN }, forceDeprecationState = deprecationManager.deprecationStateOverride.value, + forceDeterministicEncryption = textSecurePreferences.forcesDeterministicAttachmentEncryption, availableDeprecationState = listOf(null) + LegacyGroupDeprecationManager.DeprecationState.entries.toList(), deprecatedTime = deprecationManager.deprecatedTime.value, deprecatingStartTime = deprecationManager.deprecatingStartTime.value, @@ -82,6 +86,7 @@ class DebugMenuViewModel @Inject constructor( forceIncomingMessagesAsPro = textSecurePreferences.forceIncomingMessagesAsPro(), forcePostPro = textSecurePreferences.forcePostPro(), forceShortTTl = textSecurePreferences.forcedShortTTL(), + debugAvatarReupload = textSecurePreferences.debugAvatarReupload, messageProFeature = textSecurePreferences.getDebugMessageFeatures(), dbInspectorState = DatabaseInspectorState.NOT_AVAILABLE, debugSubscriptionStatuses = setOf( @@ -95,6 +100,8 @@ class DebugMenuViewModel @Inject constructor( debugProPlans = subscriptionManagers.asSequence() .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), + availableAltFileServers = TEST_FILE_SERVERS, + alternativeFileServer = textSecurePreferences.alternativeFileServer, ) ) val uiState: StateFlow @@ -304,6 +311,29 @@ class DebugMenuViewModel @Inject constructor( is Commands.PurchaseDebugPlan -> { command.plan.apply { manager.purchasePlan(plan) } } + + is Commands.ToggleDeterministicEncryption -> { + val newValue = !_uiState.value.forceDeterministicEncryption + _uiState.update { it.copy(forceDeterministicEncryption = newValue) } + textSecurePreferences.forcesDeterministicAttachmentEncryption = newValue + } + + is Commands.ToggleDebugAvatarReupload -> { + val newValue = !_uiState.value.debugAvatarReupload + _uiState.update { it.copy(debugAvatarReupload = newValue) } + textSecurePreferences.debugAvatarReupload = newValue + } + + is Commands.ResetPushToken -> { + viewModelScope.launch { + tokenFetcher.resetToken() + } + } + + is Commands.SelectAltFileServer -> { + _uiState.update { it.copy(alternativeFileServer = command.fileServer) } + textSecurePreferences.alternativeFileServer = command.fileServer + } } } @@ -396,6 +426,7 @@ class DebugMenuViewModel @Inject constructor( val showDeprecatedStateWarningDialog: Boolean, val hideMessageRequests: Boolean, val hideNoteToSelf: Boolean, + val forceDeterministicEncryption: Boolean, val forceCurrentUserAsPro: Boolean, val forceOtherUsersAsPro: Boolean, val forceIncomingMessagesAsPro: Boolean, @@ -403,6 +434,7 @@ class DebugMenuViewModel @Inject constructor( val forcePostPro: Boolean, val forceShortTTl: Boolean, val forceDeprecationState: LegacyGroupDeprecationManager.DeprecationState?, + val debugAvatarReupload: Boolean, val availableDeprecationState: List, val deprecatedTime: ZonedDateTime, val deprecatingStartTime: ZonedDateTime, @@ -410,6 +442,8 @@ class DebugMenuViewModel @Inject constructor( val debugSubscriptionStatuses: Set, val selectedDebugSubscriptionStatus: DebugSubscriptionStatus, val debugProPlans: List, + val alternativeFileServer: FileServer? = null, + val availableAltFileServers: List = emptyList(), ) enum class DatabaseInspectorState { @@ -451,5 +485,22 @@ class DebugMenuViewModel @Inject constructor( data object ToggleDatabaseInspector : Commands() data class SetDebugSubscriptionStatus(val status: DebugSubscriptionStatus) : Commands() data class PurchaseDebugPlan(val plan: DebugProPlan) : Commands() + data object ToggleDeterministicEncryption : Commands() + data object ToggleDebugAvatarReupload : Commands() + data object ResetPushToken : Commands() + data class SelectAltFileServer(val fileServer: FileServer?) : Commands() + } + + companion object { + private val TEST_FILE_SERVERS: List = listOf( + FileServer( + url = "http://potatofiles.getsession.org", + ed25519PublicKeyHex = "ff86dcd4b26d1bfec944c59859494248626d6428efc12168749d65a1b92f5e28", + ), + FileServer( + url = "http://superduperfiles.oxen.io", + ed25519PublicKeyHex = "929e33ded05e653fec04b49645117f51851f102a947e04806791be416ed76602", + ) + ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 0e518eba92..8b421e3644 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -485,16 +485,6 @@ class ConfigFactory @Inject constructor( } } } - - override fun signForPushRegistry(data: ByteArray): Map { - return factory.withGroupConfigs(accountId) { - val auth = it.groupKeys.subAccountSign(data, authData) - buildMap { - put("subkey_tag", auth.subAccount) - put("signature", auth.signature) - } - } - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt index d0d48b52e6..0cd2ca471e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt @@ -5,9 +5,11 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.service.ExpiringMessageManager @@ -26,4 +28,6 @@ abstract class DatabaseBindings { @Binds abstract fun bindMessageExpirationManagerProtocol(manager: ExpiringMessageManager): SSKEnvironment.MessageExpirationManagerProtocol + @Binds + abstract fun bindMessageProvider(provider: DatabaseAttachmentProvider): MessageDataProvider } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index b0fa5a0a4b..08ec99a3d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -29,24 +29,10 @@ interface DatabaseComponent { fun mediaDatabase(): MediaDatabase fun threadDatabase(): ThreadDatabase fun mmsSmsDatabase(): MmsSmsDatabase - fun draftDatabase(): DraftDatabase - fun pushDatabase(): PushDatabase fun groupDatabase(): GroupDatabase fun recipientDatabase(): RecipientDatabase - fun groupReceiptDatabase(): GroupReceiptDatabase - fun searchDatabase(): SearchDatabase fun lokiAPIDatabase(): LokiAPIDatabase fun lokiMessageDatabase(): LokiMessageDatabase - fun lokiUserDatabase(): LokiUserDatabase - fun lokiBackupFilesDatabase(): LokiBackupFilesDatabase - fun sessionJobDatabase(): SessionJobDatabase - fun sessionContactDatabase(): SessionContactDatabase fun reactionDatabase(): ReactionDatabase - fun emojiSearchDatabase(): EmojiSearchDatabase fun storage(): Storage - fun attachmentProvider(): MessageDataProvider - fun blindedIdMappingDatabase(): BlindedIdMappingDatabase - fun groupMemberDatabase(): GroupMemberDatabase - fun expirationConfigurationDatabase(): ExpirationConfigurationDatabase - fun configDatabase(): ConfigDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index d7bb41a857..8fe5cfd092 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SearchDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.migration.DatabaseMigrationManager import javax.inject.Provider @@ -134,10 +133,6 @@ object DatabaseModule { @Singleton fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: Provider) = ExpirationConfigurationDatabase(context, openHelper) - @Provides - @Singleton - fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: Provider): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) - @Provides @Singleton fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: Provider): ConfigDatabase = ConfigDatabase(context, openHelper) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt deleted file mode 100644 index 59f5a2384e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.dependencies - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import org.session.libsession.messaging.groups.GroupScope -import org.session.libsession.utilities.TextSecurePreferences -import javax.inject.Named -import javax.inject.Singleton - -const val POLLER_SCOPE = "poller_coroutine_scope" - -@Module -@InstallIn(SingletonComponent::class) -object SessionUtilModule { - - @OptIn(DelicateCoroutinesApi::class) - @Provides - @Named(POLLER_SCOPE) - fun providePollerScope(): CoroutineScope = GlobalScope - - @Provides - @Named(POLLER_SCOPE) - fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) - - @Provides - @Singleton - fun provideGroupScope() = GroupScope() -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java index 93293bcff0..188df96411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java @@ -93,6 +93,7 @@ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_SEARCH) { InputMethodManager inputMethodManager = (InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(searchText.getWindowToken(), 0); + return true; } return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt index 8ab1f36077..d702e57ef5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.glide -import android.app.Application -import androidx.work.await +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.debounce @@ -13,14 +13,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.util.GroupInfo -import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker +import org.thoughtcrime.securesms.attachments.AvatarDownloadManager import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject @@ -30,11 +31,13 @@ import javax.inject.Singleton @OptIn(FlowPreview::class) @Singleton class RecipientAvatarDownloadManager @Inject constructor( - private val application: Application, private val prefs: TextSecurePreferences, private val configFactory: ConfigFactory, @ManagerScope scope: CoroutineScope, + private val avatarDownloadManager: AvatarDownloadManager, ) { + private val avatarBulkDownloadSemaphore = Semaphore(5) + init { scope.launch { prefs.watchLocalNumber() @@ -49,26 +52,41 @@ class RecipientAvatarDownloadManager @Inject constructor( flowOf(emptySet()) } } - .scan(State(emptySet())) { acc, newSet -> + .scan(State()) { acc, newSet -> val toDownload = newSet - acc.downloadedAvatar + val coroutineJobs = acc.downloadingJob.toMutableMap() for (file in toDownload) { Log.d(TAG, "Downloading $file") - RemoteFileDownloadWorker.enqueue(application, file) + coroutineJobs[file] = scope.launch { + enqueueDownload(file) + } } val toRemove = acc.downloadedAvatar - newSet for (file in toRemove) { Log.d(TAG, "Cancelling downloading of $file") - RemoteFileDownloadWorker.cancel(application, file) + coroutineJobs.remove(file)?.cancel() } - acc.copy(downloadedAvatar = newSet) + acc.copy(downloadedAvatar = newSet, downloadingJob = coroutineJobs) } .collect() // Look at all the avatar URLs stored in the config and download them if necessary } } + private suspend fun enqueueDownload(file: RemoteFile) { + try { + avatarBulkDownloadSemaphore.withPermit { + avatarDownloadManager.download(file) + } + } catch (e: Exception) { + if (e !is CancellationException) { + Log.w(TAG, "Error downloading avatar $file", e) + } + } + } + fun getAllAvatars(): Set { val (contacts, groups) = configFactory.withUserConfigs { configs -> configs.contacts.all() to configs.userGroups.all() @@ -115,7 +133,8 @@ class RecipientAvatarDownloadManager @Inject constructor( } private data class State( - val downloadedAvatar: Set + val downloadedAvatar: Set = emptySet(), + val downloadingJob: Map = emptyMap() ) companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/RemoteFileLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/RemoteFileLoader.kt index 46097578c3..02e833e2f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/RemoteFileLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/RemoteFileLoader.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.glide -import android.content.Context -import androidx.work.WorkInfo import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.Key @@ -10,17 +8,13 @@ import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.model.ModelLoader import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.session.libsession.utilities.recipients.RemoteFile -import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.attachments.LocalEncryptedFileInputStream -import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker +import org.thoughtcrime.securesms.attachments.AvatarDownloadManager import org.thoughtcrime.securesms.dependencies.ManagerScope import java.io.InputStream import java.security.MessageDigest @@ -31,9 +25,8 @@ import javax.inject.Provider * A Glide [ModelLoader] for [RemoteFile]s */ class RemoteFileLoader @Inject constructor( - @param:ApplicationContext private val context: Context, @param:ManagerScope private val scope: CoroutineScope, - private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, + private val avatarDownloadManager: AvatarDownloadManager, ) : ModelLoader { override fun buildLoadData( model: RemoteFile, @@ -57,25 +50,7 @@ class RemoteFileLoader @Inject constructor( ) { job = scope.launch { try { - val downloadedFile = RemoteFileDownloadWorker.computeFileName(context, file) - - // Check if the file already exists in the local storage, otherwise enqueue a download and - // wait for it to complete. - if (!downloadedFile.exists()) { - RemoteFileDownloadWorker.enqueue(context, file) - .first { it?.state == WorkInfo.State.FAILED || it?.state == WorkInfo.State.SUCCEEDED } - } - - val stream = localEncryptedFileInputStreamFactory.create(downloadedFile) - - if (stream.meta.hasPermanentDownloadError) { - stream.close() - throw NonRetryableException( - "File download failed permanently for $file" - ) - } - - callback.onDataReady(stream) + callback.onDataReady(avatarDownloadManager.download(file)) } catch (e: CancellationException) { Log.i(TAG, "Download cancelled for file: $file") diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index d376e79242..141cb8233b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -12,9 +12,12 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address @@ -27,6 +30,8 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.notifications.PushRegistryV2 @HiltWorker class GroupLeavingWorker @AssistedInject constructor( @@ -35,6 +40,8 @@ class GroupLeavingWorker @AssistedInject constructor( private val storage: Storage, private val configFactory: ConfigFactory, private val groupScope: GroupScope, + private val tokenFetcher: TokenFetcher, + private val pushRegistryV2: PushRegistryV2, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val groupId = requireNotNull(inputData.getString(KEY_GROUP_ID)) { @@ -50,6 +57,32 @@ class GroupLeavingWorker @AssistedInject constructor( storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupLeaving::class.java) storage.insertGroupInfoLeaving(groupId) + // Best effort to unsubscribe ourselves from the registration server. + // Note that this process can only be done on the device that is leaving the group, + // on a linked device, we might not have the credentials to do so. + val currentToken = tokenFetcher.token.value + if (currentToken != null) { + try { + val groupAuth = configFactory.getGroupAuth(groupId) + + if (groupAuth != null) { + val resp = pushRegistryV2.unregister(listOf( + pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth) + )).firstOrNull() + + check(resp?.success == true) { + "Unsubscription failed: code = ${resp?.error}, message = ${resp?.message}" + } + Log.d(TAG, "Unsubscribed from group $groupId successfully") + } + + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to unsubscribe from group $groupId", e) + } + } + try { if (group?.destroyed != true) { // Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 30f49b1978..c5e3acf8dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -4,6 +4,7 @@ import android.content.Context import com.google.protobuf.ByteString import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -46,8 +47,8 @@ import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.getGroup -import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.waitUntilGroupConfigsPushed import org.session.libsignal.protos.SignalServiceProtos.DataMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage @@ -527,7 +528,7 @@ class GroupManagerV2Impl @Inject constructor( message = promoteMessage, address = Address.fromSerialized(member.hexString), isSyncMessage = false, - ).await() + ) } } @@ -657,11 +658,13 @@ class GroupManagerV2Impl @Inject constructor( .setInviteResponse(inviteResponse) val responseMessage = GroupUpdated(responseData.build(), profile = storage.getUserProfile()) // this will fail the first couple of times :) - MessageSender.sendNonDurably( - responseMessage, - Destination.ClosedGroup(group.groupAccountId), - isSyncMessage = false - ) + runCatching { + MessageSender.sendNonDurably( + responseMessage, + Destination.ClosedGroup(group.groupAccountId), + isSyncMessage = false + ) + } } else { // If we are invited as admin, we can just update the group info ourselves configFactory.withMutableGroupConfigs(AccountId(group.groupAccountId)) { configs -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index 7db34f8cb4..f6c44c9a9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue @@ -43,6 +45,7 @@ import kotlin.time.Duration.Companion.days class GroupPoller @AssistedInject constructor( @Assisted scope: CoroutineScope, @Assisted private val groupId: AccountId, + @Assisted private val pollSemaphore: Semaphore, private val configFactoryProtocol: ConfigFactoryProtocol, private val lokiApiDatabase: LokiAPIDatabaseProtocol, private val clock: SnodeClock, @@ -107,7 +110,9 @@ class GroupPoller @AssistedInject constructor( lastState = lastState.copy(inProgress = true).also { emit(it) } - val pollResult = doPollOnce(internalPollState) + val pollResult = pollSemaphore.withPermit { + doPollOnce(internalPollState) + } lastState = lastState.copy( hadAtLeastOneSuccessfulPoll = lastState.hadAtLeastOneSuccessfulPoll || pollResult.result.isSuccess, @@ -116,7 +121,9 @@ class GroupPoller @AssistedInject constructor( ).also { emit(it) } // Notify all pending tokens - pendingTokens.forEach { it.resultCallback.send(pollResult) } + pendingTokens.forEach { + it.resultCallback.trySend(pollResult) + } pendingTokens.clear() } }.stateIn(scope, SharingStarted.Eagerly, State()) @@ -130,10 +137,12 @@ class GroupPoller @AssistedInject constructor( appVisibilityManager.isAppVisible.first { visible -> visible } // As soon as the app becomes visible, start polling + Log.d(TAG, "Requesting routine poll for group($groupId)") if (requestPollOnce().hasNonRetryableError()) { Log.v(TAG, "Error polling group $groupId and stopped polling") break } + Log.d(TAG, "Routine poll done once for group($groupId)") // As long as the app is visible, keep polling while (true) { @@ -147,10 +156,14 @@ class GroupPoller @AssistedInject constructor( break } + Log.d(TAG, "Requesting routine poll for group($groupId)") + if (requestPollOnce().hasNonRetryableError()) { Log.v(TAG, "Error polling group $groupId and stopped polling") return@launch } + + Log.d(TAG, "Routine poll done once for group($groupId)") } } } @@ -221,6 +234,8 @@ class GroupPoller @AssistedInject constructor( throw NonRetryableException("Group has been kicked") } + Log.v(TAG, "Start polling group($groupId) message snode = ${snode.ip}") + val adminKey = group.adminKey val pollingTasks = mutableListOf>>() @@ -265,7 +280,6 @@ class GroupPoller @AssistedInject constructor( Namespace.GROUP_MESSAGES() ).orEmpty() - Log.v(TAG, "Retrieving group($groupId) message since lastHash = $lastHash, snode = ${snode.publicKeySet}") SnodeAPI.sendBatchRequest( snode = snode, @@ -351,6 +365,8 @@ class GroupPoller @AssistedInject constructor( } } + Log.d(TAG, "Group($groupId) polling completed, success = ${result.isSuccess}") + if (result.isFailure) { val error = result.exceptionOrNull() Log.e(TAG, "Error polling group", error) @@ -469,6 +485,6 @@ class GroupPoller @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(scope: CoroutineScope, groupId: AccountId): GroupPoller + fun create(scope: CoroutineScope, groupId: AccountId, pollSemaphore: Semaphore): GroupPoller } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt index 5883568841..4cc652f628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.groups import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel @@ -23,12 +22,14 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.NetworkConnectivity import org.thoughtcrime.securesms.util.castAwayType @@ -55,7 +56,10 @@ class GroupPollerManager @Inject constructor( preferences: TextSecurePreferences, connectivity: NetworkConnectivity, pollFactory: GroupPoller.Factory, + @param:ManagerScope private val managerScope: CoroutineScope, ) : OnAppStartupComponent { + private val groupPollerSemaphore = Semaphore(20) + @Suppress("OPT_IN_USAGE") private val groupPollers: StateFlow> = combine( @@ -109,6 +113,7 @@ class GroupPollerManager @Inject constructor( poller = pollFactory.create( scope = scope, groupId = groupId, + pollSemaphore = groupPollerSemaphore, ), scope = scope ) @@ -118,7 +123,7 @@ class GroupPollerManager @Inject constructor( } } - .stateIn(GlobalScope, SharingStarted.Eagerly, emptyMap()) + .stateIn(managerScope, SharingStarted.Eagerly, emptyMap()) @Suppress("OPT_IN_USAGE") diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 154ef35869..93159ed2ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -6,7 +6,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.LokiAPIDatabase @@ -30,7 +29,7 @@ class OpenGroupManager @Inject constructor( // for the user to see if the server they are adding is reachable. // The addition of the community to the config later will always succeed and the poller // will be started regardless of the server's status. - val caps = OpenGroupApi.getCapabilities(server, serverPubKeyHex = publicKey).await() + val caps = OpenGroupApi.getCapabilities(server, serverPubKeyHex = publicKey) lokiAPIDatabase.setServerCapabilities(server, caps.capabilities) // We should be good, now go ahead and add the community to the config diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index b225f45744..fa3f8c8726 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -18,12 +18,10 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.SearchResult -import org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt index e6951effbf..ab7961b1b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt @@ -45,8 +45,11 @@ class JoinCommunityViewModel @Inject constructor( private val qrDebounceTime = 3000L init { - OpenGroupApi.getDefaultServerCapabilities().map { - OpenGroupApi.getDefaultRoomsIfNeeded() + viewModelScope.launch(Dispatchers.Default) { + runCatching { + OpenGroupApi.getDefaultServerCapabilities() + OpenGroupApi.getDefaultRoomsIfNeeded() + } } viewModelScope.launch(Dispatchers.Default) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java index f1d5a5ec57..314dd6448c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -5,17 +5,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.utilities.MediaTypes; import org.session.libsignal.utilities.Log; import android.util.Pair; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.MediaUtil; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -63,19 +60,4 @@ public boolean canResize(@Nullable Attachment attachment) { return attachment != null && MediaUtil.isImage(attachment) && !MediaUtil.isGif(attachment); } - public MediaStream getResizedMedia(@NonNull Context context, @NonNull Attachment attachment) - throws IOException - { - if (!canResize(attachment)) { - throw new UnsupportedOperationException("Cannot resize this content type"); - } - - try { - // XXX - This is loading everything into memory! We want the send path to be stream-like. - BitmapUtil.ScaleResult scaleResult = BitmapUtil.createScaledBytes(context, new DecryptableUri(attachment.getDataUri()), this); - return new MediaStream(new ByteArrayInputStream(scaleResult.getBitmap()), MediaTypes.IMAGE_JPEG, scaleResult.getWidth(), scaleResult.getHeight()); - } catch (BitmapDecodingException e) { - throw new IOException(e); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index 92b98db3ed..d5c9846245 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -16,21 +16,14 @@ */ package org.thoughtcrime.securesms.notifications -import android.Manifest import android.annotation.SuppressLint -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager import android.database.Cursor import android.graphics.Bitmap -import android.os.AsyncTask import android.text.TextUtils -import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import coil3.ImageLoader import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.libsession_util.util.BlindKeyAPI @@ -41,7 +34,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy -import org.session.libsession.utilities.TextSecurePreferences.Companion.getRepeatAlertsCount import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.RecipientData @@ -49,8 +41,6 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Util -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.crypto.KeyPairUtilities.getUserED25519KeyPair import org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED @@ -67,11 +57,9 @@ import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol.canUserReplyToNotification import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.WEBRTC_NOTIFICATION -import java.util.concurrent.Executor -import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import javax.inject.Provider import kotlin.concurrent.Volatile /** @@ -87,7 +75,8 @@ class DefaultMessageNotifier @Inject constructor( private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, private val mmsSmsDatabase: MmsSmsDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val imageLoader: Provider, ) : MessageNotifier { override fun setVisibleThread(threadId: Long) { visibleThread = threadId @@ -97,14 +86,6 @@ class DefaultMessageNotifier @Inject constructor( homeScreenVisible = isVisible } - override fun setLastDesktopActivityTimestamp(timestamp: Long) { - lastDesktopActivityTimestamp = timestamp - } - - override fun cancelDelayedNotifications() { - executor.cancel() - } - private fun cancelActiveNotifications(context: Context): Boolean { val notifications = ServiceUtil.getNotificationManager(context) val hasNotifications = notifications.activeNotifications.size > 0 @@ -175,12 +156,7 @@ class DefaultMessageNotifier @Inject constructor( } override fun updateNotification(context: Context, threadId: Long) { - if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { - Log.i(TAG, "Scheduling delayed notification...") - executor.execute(DelayedNotification(context, threadId)) - } else { - updateNotification(context, threadId, true) - } + updateNotification(context, threadId, true) } override fun updateNotification(context: Context, threadId: Long, signal: Boolean) { @@ -236,7 +212,6 @@ class DefaultMessageNotifier @Inject constructor( // early exit if (nothingToDo || localNumber == null) { cancelActiveNotifications(context) - clearReminder(context) return } @@ -262,13 +237,30 @@ class DefaultMessageNotifier @Inject constructor( } // Normal notifications (unchanged behavior, but uses normalItems) - if (normalItems.hasMultipleThreads()) { + if (normalItems.notificationCount == 0) { + // There's no notification at all, we'll remove the "group summary notification" + // here, exists or not. Other notifications will be cleaned up in + // `cancelOrphanedNotifications` + ServiceUtil.getNotificationManager(context) + .cancel(SUMMARY_NOTIFICATION_ID) + } + else if (normalItems.hasMultipleThreads() || hasGroupSummaryNotification(context)) { + // The case of "grouped notifications". + // This includes: + // 1. One notification per thread + // 2. A summary notification for all threads + // + // We will first enter this state when we have multiple threads to show, + // and remain so until the user clears all notifications. This is to avoid + // going back into single-thread mode as it can cause excessive notification + // alerts. for (threadId in normalItems.threads) { val perThread = NotificationState(normalItems.getNotificationsForThread(threadId)) sendSingleThreadNotification(context, perThread, false, true) } - sendMultipleThreadNotification(context, normalItems, playNotificationAudio) - } else if (normalItems.notificationCount > 0) { + sendGroupSummaryNotification(context, normalItems, playNotificationAudio) + } else { + // The case of showing just one single-threaded notification. sendSingleThreadNotification(context, normalItems, playNotificationAudio, false) } @@ -278,17 +270,8 @@ class DefaultMessageNotifier @Inject constructor( sendSingleThreadNotification(context, perThread,false,false) } - // If nothing to display at all, clear everything (including reminders) - if (normalItems.notificationCount == 0 && requestItems.notificationCount == 0) { - // Request-aware cleanup (keeps active request notifs alive) - cancelOrphanedNotifications(context, normalItems) - clearReminder(context) - return - } - - if (playNotificationAudio) { - scheduleReminder(context, reminderCount) - } + // Clean up any notifications that are no longer in our state + cancelOrphanedNotifications(context, normalItems) } catch (e: Exception) { Log.e(TAG, "Error creating notification", e) } @@ -299,7 +282,15 @@ class DefaultMessageNotifier @Inject constructor( } } + private fun hasGroupSummaryNotification(context: Context): Boolean { + return ServiceUtil.getNotificationManager(context) + .activeNotifications + ?.any { it.id == SUMMARY_NOTIFICATION_ID } == true + + } + // Note: The `signal` parameter means "play an audio signal for the notification". + @SuppressLint("MissingPermission") private fun sendSingleThreadNotification( context: Context, notificationState: NotificationState, @@ -308,23 +299,11 @@ class DefaultMessageNotifier @Inject constructor( ) { Log.i(TAG, "sendSingleThreadNotification() signal: $signal bundled: $bundled") - if (notificationState.notifications.isEmpty()) { - if (!bundled) { - cancelActiveNotifications(context) - } - Log.i(TAG, "Empty notification state. Skipping.") - return - } - // Bail early if the existing displayed notification has the same content as what we are trying to send now val notifications = notificationState.notifications // Use dedicated id + group for request notifications val isRequest = notifications.firstOrNull()?.isMessageRequest == true - val notificationId = if (isRequest) { - (SUMMARY_NOTIFICATION_ID + notifications[0].threadId).toInt() - } else { - (SUMMARY_NOTIFICATION_ID + (if (bundled) notifications[0].threadId else 0)).toInt() - } + val notificationId = (SUMMARY_NOTIFICATION_ID + notifications[0].threadId).toInt() val contentSignature = notifications.map { getNotificationSignature(it) @@ -332,18 +311,23 @@ class DefaultMessageNotifier @Inject constructor( val existingNotifications = ServiceUtil.getNotificationManager(context).activeNotifications - val existingSignature = if (isRequest) { - // For requests: match BOTH id and tag to detect duplicates correctly - existingNotifications - .firstOrNull { it.id == notificationId && REQUEST_TAG == it.tag } - ?.notification?.extras?.getString(CONTENT_SIGNATURE) - } else { - existingNotifications - .firstOrNull { it.id == notificationId } - ?.notification?.extras?.getString(CONTENT_SIGNATURE) - } + val exitingNotification = existingNotifications.firstOrNull { + if (isRequest) { + it.id == notificationId && REQUEST_TAG == it.tag + } else { + it.id == notificationId + } + }?.notification - if (existingSignature == contentSignature) { + val contentChanged = exitingNotification?.extras?.getString( + CONTENT_SIGNATURE + ) != contentSignature + + val bundleStateChanged = exitingNotification == null || ( + bundled != (exitingNotification.group == NOTIFICATION_GROUP) + ) + + if (!contentChanged && !bundleStateChanged) { Log.i(TAG, "Skipping duplicate single thread notification for ID $notificationId") return } @@ -351,7 +335,8 @@ class DefaultMessageNotifier @Inject constructor( val builder = SingleRecipientNotificationBuilder( context, getNotificationPrivacy(context), - avatarUtils + avatarUtils, + imageLoader, ) builder.putStringExtra(CONTENT_SIGNATURE, contentSignature) @@ -396,8 +381,10 @@ class DefaultMessageNotifier @Inject constructor( builder.setContentIntent(notificationItem.getPendingIntent(context)) builder.setDeleteIntent(notificationState.getDeleteIntent(context)) - builder.setOnlyAlertOnce(!signal) - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + // Turn on the OnlyAlertOnce if the content doesn't change. If + // the content doesn't change, we don't want to keep alerting the user. + val alertOnce = !contentChanged + builder.setOnlyAlertOnce(alertOnce) builder.setAutoCancel(true) val replyMethod = ReplyMethod.forRecipient(context, messageOriginator) @@ -436,7 +423,7 @@ class DefaultMessageNotifier @Inject constructor( builder.addMessageBody(item.recipient, item.individualRecipient, item.text) } - if (signal) { + if (signal && contentChanged) { builder.setAlarms(notificationState.getRingtone(context)) builder.setTicker( notificationItem.individualRecipient, @@ -447,34 +434,21 @@ class DefaultMessageNotifier @Inject constructor( // requests go to a separate group; normal keeps existing behavior if (bundled || isRequest) { builder.setGroup(if (isRequest) REQUESTS_GROUP else NOTIFICATION_GROUP) - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) } val notification = builder.build() - // TODO - ACL to fix this properly & will do on 2024-08-26, but just skipping for now so review can start - if (ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - // TODO: Consider calling - // ActivityCompat#requestPermissions - // here to request the missing permissions, and then overriding - // public void onRequestPermissionsResult(int requestCode, String[] permissions, - // int[] grantResults) - // to handle the case where the user grants the permission. See the documentation - // for ActivityCompat#requestPermissions for more details. - return - } + if (hasNotificationPermissions(context)) { + if (isRequest) { + NotificationManagerCompat.from(context) + .notify(REQUEST_TAG, notificationId, notification) + } else { + NotificationManagerCompat.from(context).notify(notificationId, notification) + } - if (isRequest) { - NotificationManagerCompat.from(context).notify(REQUEST_TAG, notificationId, notification) - } else { - NotificationManagerCompat.from(context).notify(notificationId, notification) + Log.i(TAG, "Posted notification. $notificationId") } - - Log.i(TAG, "Posted notification. $notification") } private fun getNotificationSignature(notification: NotificationItem): String { @@ -482,7 +456,8 @@ class DefaultMessageNotifier @Inject constructor( } // Note: The `signal` parameter means "play an audio signal for the notification". - private fun sendMultipleThreadNotification( + @SuppressLint("MissingPermission") + private fun sendGroupSummaryNotification( context: Context, notificationState: NotificationState, signal: Boolean @@ -505,7 +480,7 @@ class DefaultMessageNotifier @Inject constructor( return } - val builder = MultipleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + val builder = GroupSummaryNotificationBuilder(context, getNotificationPrivacy(context)) builder.putStringExtra(CONTENT_SIGNATURE, contentSignature) builder.setMessageCount(notificationState.notificationCount, notificationState.threadCount) @@ -513,7 +488,7 @@ class DefaultMessageNotifier @Inject constructor( builder.setGroup(NOTIFICATION_GROUP) builder.setDeleteIntent(notificationState.getDeleteIntent(context)) builder.setOnlyAlertOnce(!signal) - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) builder.setAutoCancel(true) val messageIdTag = notifications[0].timestamp.toString() @@ -567,25 +542,16 @@ class DefaultMessageNotifier @Inject constructor( builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag) - // TODO - ACL to fix this properly & will do on 2024-08-26, but just skipping for now so review can start - if (ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - // TODO: Consider calling - // ActivityCompat#requestPermissions - // here to request the missing permissions, and then overriding - // public void onRequestPermissionsResult(int requestCode, String[] permissions, - // int[] grantResults) - // to handle the case where the user grants the permission. See the documentation - // for ActivityCompat#requestPermissions for more details. - return + + if (hasNotificationPermissions(context)) { + val notification = builder.build() + NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification) + Log.i(TAG, "Posted notification. $notification") } + } - val notification = builder.build() - NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification) - Log.i(TAG, "Posted notification. $notification") + private fun hasNotificationPermissions(context: Context): Boolean { + return NotificationManagerCompat.from(context).areNotificationsEnabled() } private fun constructNotificationState(context: Context, cursor: Cursor): NotificationState { @@ -722,7 +688,7 @@ class DefaultMessageNotifier @Inject constructor( } } - Log.w(TAG, "Adding incoming message notification: ${body}") + Log.w(TAG, "Adding incoming message notification: messageLength = ${body.length}") // Add incoming message notification notificationState.addNotification( @@ -777,7 +743,7 @@ class DefaultMessageNotifier @Inject constructor( Log.w( TAG, - "Adding reaction notification: ${emoji} to our message ID ${record.getId()}" + "Adding reaction notification to our message ID ${record.getId()}" ) notificationState.addNotification( @@ -850,128 +816,6 @@ class DefaultMessageNotifier @Inject constructor( return null } - private fun scheduleReminder(context: Context, count: Int) { - if (count >= getRepeatAlertsCount(context)) { - return - } - - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) - alarmIntent.putExtra("reminder_count", count) - - val pendingIntent = PendingIntent.getBroadcast( - context, - 0, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val timeout = TimeUnit.MINUTES.toMillis(2) - - alarmManager[AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout] = pendingIntent - } - - override fun clearReminder(context: Context) { - val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) - val pendingIntent = PendingIntent.getBroadcast( - context, - 0, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.cancel(pendingIntent) - } - - class ReminderReceiver : BroadcastReceiver() { - @SuppressLint("StaticFieldLeak") - override fun onReceive(context: Context, intent: Intent) { - object : AsyncTask() { - - override fun doInBackground(vararg params: Void?): Void? { - val reminderCount = intent.getIntExtra("reminder_count", 0) - ApplicationContext.getInstance(context).messageNotifier.updateNotification( - context, - true, - reminderCount + 1 - ) - return null - } - - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) - } - - companion object { - const val REMINDER_ACTION: String = - "network.loki.securesms.MessageNotifier.REMINDER_ACTION" - } - } - - private class DelayedNotification(private val context: Context, private val threadId: Long) : - Runnable { - private val canceled = AtomicBoolean(false) - - private val delayUntil: Long - - init { - this.delayUntil = System.currentTimeMillis() + DELAY - } - - override fun run() { - val delayMillis = delayUntil - System.currentTimeMillis() - Log.i(TAG, "Waiting to notify: $delayMillis") - - if (delayMillis > 0) { - Util.sleep(delayMillis) - } - - if (!canceled.get()) { - Log.i(TAG, "Not canceled, notifying...") - ApplicationContext.getInstance(context).messageNotifier.updateNotification( - context, - threadId, - true - ) - ApplicationContext.getInstance(context).messageNotifier.cancelDelayedNotifications() - } else { - Log.w(TAG, "Canceled, not notifying...") - } - } - - fun cancel() { - canceled.set(true) - } - - companion object { - private val DELAY = TimeUnit.SECONDS.toMillis(5) - } - } - - private class CancelableExecutor { - private val executor: Executor = Executors.newSingleThreadExecutor() - private val tasks: MutableSet = HashSet() - - fun execute(runnable: DelayedNotification) { - synchronized(tasks) { tasks.add(runnable) } - - val wrapper = Runnable { - runnable.run() - synchronized(tasks) { - tasks.remove(runnable) - } - } - - executor.execute(wrapper) - } - - fun cancel() { - synchronized(tasks) { - for (task in tasks) { - task.cancel() - } - } - } - } - companion object { private val TAG: String = DefaultMessageNotifier::class.java.simpleName @@ -992,7 +836,6 @@ class DefaultMessageNotifier @Inject constructor( private const val REQUEST_TAG = "message_request" private val MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(5) - private val DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1) @Volatile private var visibleThread: Long = -1 @@ -1000,11 +843,7 @@ class DefaultMessageNotifier @Inject constructor( @Volatile private var homeScreenVisible = false - @Volatile - private var lastDesktopActivityTimestamp: Long = -1 - @Volatile private var lastAudibleNotification: Long = -1 - private val executor = CancelableExecutor() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java deleted file mode 100644 index e95bbbb6f8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; - -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -public class DeleteNotificationReceiver extends BroadcastReceiver { - - public static String DELETE_NOTIFICATION_ACTION = "network.loki.securesms.DELETE_NOTIFICATION"; - - public static String EXTRA_IDS = "message_ids"; - public static String EXTRA_MMS = "is_mms"; - - @Override - public void onReceive(final Context context, Intent intent) { - if (DELETE_NOTIFICATION_ACTION.equals(intent.getAction())) { - ApplicationContext.getInstance(context).getMessageNotifier().clearReminder(context); - - final long[] ids = intent.getLongArrayExtra(EXTRA_IDS); - final boolean[] mms = intent.getBooleanArrayExtra(EXTRA_MMS); - - if (ids == null || mms == null || ids.length != mms.length) return; - - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - for (int i=0;i = LinkedList() init { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java index c86b46bdce..301c04a5a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -228,9 +228,16 @@ public PendingIntent getDeleteIntent(Context context) { mms[index++] = notificationItem.isMms(); } + long[] threadIds = new long[threads.size()]; + index = 0; + for (long thread : threads) { + threadIds[index++] = thread; + } + Intent intent = new Intent(context, DeleteNotificationReceiver.class); intent.setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION); intent.putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids); + intent.putExtra(DeleteNotificationReceiver.EXTRA_THREAD_IDS, threadIds); intent.putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index 532d78451d..480b2819c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -7,11 +7,9 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager; -import org.session.libsession.messaging.sending_receiving.pollers.Poller; import org.session.libsession.messaging.sending_receiving.pollers.PollerManager; import org.session.libsession.utilities.Debouncer; import org.session.libsignal.utilities.ThreadUtils; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.util.AvatarUtils; import java.util.concurrent.TimeUnit; @@ -49,13 +47,6 @@ public void setHomeScreenVisible(boolean isVisible) { wrapped.setHomeScreenVisible(isVisible); } - @Override - public void setLastDesktopActivityTimestamp(long timestamp) { wrapped.setLastDesktopActivityTimestamp(timestamp);} - - - @Override - public void cancelDelayedNotifications() { wrapped.cancelDelayedNotifications(); } - @Override public void updateNotification(@NonNull Context context) { boolean isCaughtUp = true; @@ -112,9 +103,6 @@ public void updateNotification(@androidx.annotation.NonNull Context context, boo } } - @Override - public void clearReminder(@NonNull Context context) { wrapped.clearReminder(context); } - private void performOnBackgroundThreadIfNeeded(Runnable r) { if (Looper.myLooper() == Looper.getMainLooper()) { ThreadUtils.queue(() -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationModule.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationModule.kt new file mode 100644 index 0000000000..b8c2513523 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationModule.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.notifications + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.sync.Semaphore +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class PushNotificationModule { + + @Qualifier + @Retention(AnnotationRetention.SOURCE) + annotation class PushProcessingSemaphore + + @Provides + @Singleton + @PushProcessingSemaphore + fun providePushProcessingSemaphore(): Semaphore { + return Semaphore(1) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index dcce84bcd2..3208180414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -1,52 +1,54 @@ package org.thoughtcrime.securesms.notifications import android.content.Context +import androidx.work.await import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -import org.session.libsession.database.userAuth +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.IdPrefix +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.PushRegistrationDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.util.castAwayType +import java.util.EnumSet +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton -private const val TAG = "PushRegistrationHandler" - /** - * A class that listens to the config, user's preference, token changes and - * register/unregister push notification accordingly. - * - * This class DOES NOT handle the legacy groups push notification. + * A PN registration handler that watches for changes in the configs, and arrange the desired state + * of push registrations into the database, and triggers [PushRegistrationWorker] to process them. */ @Singleton -class PushRegistrationHandler -@Inject -constructor( +class PushRegistrationHandler @Inject constructor( private val configFactory: ConfigFactory, private val preferences: TextSecurePreferences, private val tokenFetcher: TokenFetcher, @param:ApplicationContext private val context: Context, - private val registry: PushRegistryV2, - private val storage: Storage, - @param:ManagerScope private val scope: CoroutineScope + @param:ManagerScope private val scope: CoroutineScope, + @param:PushNotificationModule.PushProcessingSemaphore + private val semaphore: Semaphore, + private val storage: StorageProtocol, + private val pushRegistrationDatabase: PushRegistrationDatabase, ) : OnAppStartupComponent { private var job: Job? = null @@ -55,90 +57,78 @@ constructor( override fun onPostAppStarted() { require(job == null) { "Job is already running" } - job = scope.launch(Dispatchers.Default) { - combine( - (configFactory.configUpdateNotifications as Flow) - .debounce(500L) - .onStart { emit(Unit) }, - preferences.watchLocalNumber(), - preferences.pushEnabled, - tokenFetcher.token, - ) { _, myAccountId, enabled, token -> - if (!enabled || myAccountId == null || storage.getUserED25519KeyPair() == null || token.isNullOrEmpty()) { - return@combine emptySet() - } - - setOf(SubscriptionKey(AccountId(myAccountId), token)) + getGroupSubscriptions(token) - } - .scan(emptySet() to emptySet()) { acc, current -> - acc.second to current - } - .collect { (prev, current) -> - val added = current - prev - val removed = prev - current - if (added.isNotEmpty()) { - Log.d(TAG, "Adding ${added.size} new subscriptions") - } - if (removed.isNotEmpty()) { - Log.d(TAG, "Removing ${removed.size} subscriptions") + job = scope.launch { + val firstRun = AtomicBoolean(true) + + @Suppress("OPT_IN_USAGE") + preferences.watchLocalNumber() + .filterNotNull() + .distinctUntilChanged() + .flatMapLatest { localNumber -> + if (hasCoreIdentity()) { + combine( + configFactory.userConfigsChanged( + onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS), + debounceMills = 500 + ) + .castAwayType() + .onStart { emit(Unit) }, + preferences.pushEnabled, + tokenFetcher.token.filterNotNull().filter { !it.isBlank() } + ) { _, enabled, token -> + if (enabled) { + desiredSubscriptions(localNumber, token) + } else { + emptyList() + } + } + } else { + emptyFlow() } - - for (key in added) { - PushRegistrationWorker.schedule( - context = context, - token = key.token, - accountId = key.accountId, - ) + } + .distinctUntilChanged() + .collectLatest { desiredRegistrations -> + val changes = semaphore.withPermit { + pushRegistrationDatabase.ensureRegistrations(desiredRegistrations) } - supervisorScope { - for (key in removed) { - PushRegistrationWorker.cancelRegistration( - context = context, - accountId = key.accountId, - ) - - launch { - Log.d(TAG, "Unregistering push token for account: ${key.accountId}") - try { - val swarmAuth = swarmAuthForAccount(key.accountId) - ?: throw IllegalStateException("No SwarmAuth found for account: ${key.accountId}") - - registry.unregister( - token = key.token, - swarmAuth = swarmAuth, - ) + Log.d(TAG, "Push registration changes: $changes") - Log.d(TAG, "Successfully unregistered push token for account: ${key.accountId}") - } catch (e: Exception) { - if (e !is CancellationException) { - Log.e(TAG, "Failed to unregister push token for account: ${key.accountId}", e) - } - } - } - } + if (firstRun.compareAndSet(true, false) || changes > 0) { + PushRegistrationWorker.enqueue(context, delay = null).await() } } } } - private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth? { - return when (accountId.prefix) { - IdPrefix.STANDARD -> storage.userAuth?.takeIf { it.accountId == accountId } - IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) - else -> null // Unsupported account ID prefix + /** + * Build desired subscriptions: self (local number) + any group that shouldPoll. + * */ + private fun desiredSubscriptions(localNumber: String, token: String): List = + buildList { + val input = PushRegistrationDatabase.Input(pushToken = token) + + add(PushRegistrationDatabase.Registration(accountId = localNumber, input = input)) + + val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + for (group in groups) { + if (group.shouldPoll) { + add( + PushRegistrationDatabase.Registration( + accountId = group.groupAccountId, + input = input + ) + ) + } + } } - } - private fun getGroupSubscriptions( - token: String - ): Set { - return configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - .asSequence() - .filter { it.shouldPoll } - .mapTo(hashSetOf()) { SubscriptionKey(accountId = AccountId(it.groupAccountId), token = token) } + private fun hasCoreIdentity(): Boolean { + return preferences.getLocalNumber() != null && storage.getUserED25519KeyPair() != null } - private data class SubscriptionKey(val accountId: AccountId, val token: String) -} \ No newline at end of file + companion object { + private const val TAG = "PushRegistrationHandler" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index f9c3810bbd..68b8f9a2f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -2,91 +2,240 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import androidx.hilt.work.HiltWorker -import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters -import androidx.work.await -import androidx.work.impl.background.systemjob.setRequiredNetworkRequest import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace +import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth +import org.session.libsession.messaging.sending_receiving.notifications.Response +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.database.PushRegistrationDatabase +import org.thoughtcrime.securesms.util.getRootCause import java.time.Duration +import java.time.Instant +/** + * Worker to process pending push registrations. + */ @HiltWorker class PushRegistrationWorker @AssistedInject constructor( - @Assisted val context: Context, - @Assisted val params: WorkerParameters, - val registry: PushRegistryV2, - val storage: Storage, - val configFactory: ConfigFactory, + @Assisted private val context: Context, + @Assisted params: WorkerParameters, + private val registry: PushRegistryV2, + private val storage: StorageProtocol, + private val pushRegistrationDatabase: PushRegistrationDatabase, + private val configFactory: ConfigFactoryProtocol, + private val prefs: TextSecurePreferences, + @param:PushNotificationModule.PushProcessingSemaphore + private val semaphore: Semaphore, ) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - val accountId = checkNotNull(inputData.getString(ARG_ACCOUNT_ID) - ?.let(AccountId::fromStringOrNull)) { - "PushRegistrationWorker requires a valid account ID" - } + override suspend fun doWork(): Result = semaphore.withPermit { + val work = pushRegistrationDatabase.getPendingRegistrationWork( + limit = MAX_REGISTRATIONS_PER_RUN + ) - val token = checkNotNull(inputData.getString(ARG_TOKEN)) { - "PushRegistrationWorker requires a valid FCM token" - } + Log.d( + TAG, + "Processing ${work.register.size} registrations and ${work.unregister.size} unregisters" + ) + + supervisorScope { + val unregisterResults = async { + batchRequest( + items = work.unregister, + buildRequest = { r -> + registry.buildUnregisterRequest( + r.input.pushToken, + swarmAuthForAccount(AccountId(r.accountId)) + ) + }, + sendBatchRequest = registry::unregister + ) + } + + val registerResults = async { + batchRequest( + items = work.register, + buildRequest = { r -> + val accountId = AccountId(r.accountId) + registry.buildRegisterRequest( + token = r.input.pushToken, + swarmAuth = swarmAuthForAccount(accountId), + namespaces = if (accountId.prefix == IdPrefix.GROUP) { + GROUP_PUSH_NAMESPACES + } else { + REGULAR_PUSH_NAMESPACES + } + ) + }, + sendBatchRequest = registry::register + ) + } + + + + pushRegistrationDatabase.updateRegistrations( + registerResults.await().map { (r, result) -> + PushRegistrationDatabase.RegistrationWithState( + accountId = r.accountId, + input = r.input, + state = when { + result.isSuccess -> { + PushRegistrationDatabase.RegistrationState.Registered( + due = Instant.now().plus(Duration.ofDays(RE_REGISTER_INTERVAL_DAYS)), + ) + } - Log.d(TAG, "Registering push token for account: $accountId with token: ${token.substring(0..10)}") + result.isFailure -> { + val exception = result.exceptionOrNull()!! + if (exception.getRootCause() != null) { + Log.e(TAG, "Push registration failed permanently", exception) + PushRegistrationDatabase.RegistrationState.PermanentError + } else { + val numRetried = + (r.state as? PushRegistrationDatabase.RegistrationState.Error)?.numRetried?.plus( + 1 + ) ?: 0 - val (swarmAuth, namespaces) = when (accountId.prefix) { - IdPrefix.STANDARD -> { - val auth = requireNotNull(storage.userAuth) { - "PushRegistrationWorker requires user authentication to register push notifications" + Log.e( + TAG, + "Push registration failed (${exception.message}), retried $numRetried times", + ) + + // Exponential backoff: 15s, 30s, 1m, 2m, 4m, capped at 4m + PushRegistrationDatabase.RegistrationState.Error( + due = Instant.now() + Duration.ofSeconds( + 15L * (1 shl minOf( + numRetried, + 4 + )) + ), + numRetried = numRetried, + ) + } + } + + else -> error("Unreachable") + } + ) } + ) - // A standard account ID means ourselves, so we use the local auth. - require(accountId == auth.accountId) { - "PushRegistrationWorker can only register the local account ID" + pushRegistrationDatabase.removeRegistrations(unregisterResults.await().map { + if (it.second.isFailure) { + Log.e(TAG, "Push unregistration failed: (${it.second.exceptionOrNull()?.message})") } - auth to REGULAR_PUSH_NAMESPACES - } - IdPrefix.GROUP -> { - requireNotNull(configFactory.getGroupAuth(accountId)) to GROUP_PUSH_NAMESPACES - } - else -> { - throw IllegalArgumentException("Unsupported account ID prefix: ${accountId.prefix}") + PushRegistrationDatabase.Registration( + accountId = it.first.accountId, + input = it.first.input + ) + }) + } + + // Look for the next due registration and enqueue a new worker if needed. + val now = Instant.now() + val nextDueTime = pushRegistrationDatabase.getNextProcessTime(now) + if (nextDueTime != null) { + // Don't set the delay if the due time is in the past, so the worker runs immediately. + val delay = if (nextDueTime.isAfter(now)) Duration.between(now, nextDueTime) else null + enqueue(context, delay) + } else { + Log.d(TAG, "No further push registrations scheduled") + } + + return Result.success() + } + + private suspend inline fun batchRequest( + items: List, + buildRequest: (T) -> Req, + sendBatchRequest: suspend (Collection) -> List, + ): List>> { + val results = ArrayList>>(items.size) + + val batchRequestItems = mutableListOf() + val batchRequests = mutableListOf() + + for (item in items) { + try { + val request = buildRequest(item) + batchRequestItems += item + batchRequests += request + } catch (ec: Exception) { + results += item to kotlin.Result.failure(NonRetryableException("Failed to build a request", ec)) } } try { - registry.register(token = token, swarmAuth = swarmAuth, namespaces = namespaces) - Log.d(TAG, "Successfully registered push token for account: $accountId") - return Result.success() + val responses = sendBatchRequest(batchRequests) + responses.forEachIndexed { idx, response -> + val item = batchRequestItems[idx] + results += item to when { + response.isSuccess() -> kotlin.Result.success(Unit) + response.error == 403 -> kotlin.Result.failure(NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}")) + else -> kotlin.Result.failure(RuntimeException("Request failed: code = ${response.error}, message = ${response.message}")) + } + } } catch (e: CancellationException) { - Log.d(TAG, "Push registration cancelled for account: $accountId") throw e } catch (e: Exception) { - Log.e(TAG, "Unexpected error while registering push token for account: $accountId", e) - return if (e is NonRetryableException) Result.failure() else Result.retry() + // If the batch API fails, mark all requests in this batch as failed. + batchRequestItems.forEach { item -> + results += item to kotlin.Result.failure(e) + } } + + return results } - companion object { - private const val ARG_TOKEN = "token" - private const val ARG_ACCOUNT_ID = "account_id" + private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth { + return when { + accountId.prefix == IdPrefix.GROUP -> { + requireNotNull(configFactory.getGroupAuth(accountId)) { + "Group auth is required for group push registration" + } + } + + accountId.hexString == prefs.getLocalNumber() -> { + requireNotNull(storage.userAuth) { + "User auth is required for local number push registration" + } + } + + else -> error("Invalid account ID") + } + } + companion object { private const val TAG = "PushRegistrationWorker" + private const val WORK_NAME = "push-registration-worker" + + + private const val MAX_REGISTRATIONS_PER_RUN = 100 + private const val RE_REGISTER_INTERVAL_DAYS = 7L + private val GROUP_PUSH_NAMESPACES = listOf( Namespace.GROUP_MESSAGES(), Namespace.GROUP_INFO(), @@ -94,38 +243,28 @@ class PushRegistrationWorker @AssistedInject constructor( Namespace.GROUP_KEYS(), Namespace.REVOKED_GROUP_MESSAGES(), ) - private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) - private fun uniqueWorkName(accountId: AccountId): String { - return "push-registration-${accountId.hexString}" - } + fun enqueue(context: Context, delay: Duration?): Operation { + val builder = OneTimeWorkRequestBuilder() + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + + if (delay != null) { + builder.setInitialDelay(delay) + } else { + builder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } - fun schedule( - context: Context, - token: String, - accountId: AccountId, - ) { - val request = OneTimeWorkRequestBuilder() - .setInputData( - Data.Builder().putString(ARG_TOKEN, token) - .putString(ARG_ACCOUNT_ID, accountId.hexString).build() + val op = WorkManager.getInstance(context) + .enqueueUniqueWork( + uniqueWorkName = WORK_NAME, + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + request = builder.build() ) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) - .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) - .build() - WorkManager.getInstance(context).enqueueUniqueWork( - uniqueWorkName = uniqueWorkName(accountId), - existingWorkPolicy = ExistingWorkPolicy.REPLACE, - request = request - ) - } + Log.d(TAG, "Enqueued next worker with delay = $delay") - suspend fun cancelRegistration(context: Context, accountId: AccountId) { - WorkManager.getInstance(context) - .cancelUniqueWork(uniqueWorkName(accountId)) - .await() + return op } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 17db072713..2df1d2d781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.notifications import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -13,7 +12,6 @@ import kotlinx.serialization.json.jsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.messaging.sending_receiving.notifications.Response import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse @@ -25,33 +23,45 @@ import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device -import org.session.libsignal.utilities.retryWithUniformInterval import org.session.libsignal.utilities.toHexString import javax.inject.Inject import javax.inject.Singleton -private const val maxRetryCount = 4 +typealias SignedSubscriptionRequest = JsonObject +typealias SignedUnsubscriptionRequest = JsonObject @Singleton class PushRegistryV2 @Inject constructor( private val pushReceiver: PushReceiver, private val device: Device, - private val clock: SnodeClock, - ) { + private val clock: SnodeClock +) { + private val pnKey by lazy { + pushReceiver.getOrCreateNotificationKey() + } + suspend fun register( + requests: Collection + ): List { + return getResponseBody( + "subscribe", + Json.encodeToString(requests) + ) + } + + fun buildRegisterRequest( token: String, swarmAuth: SwarmAuth, namespaces: List - ) { - val pnKey = pushReceiver.getOrCreateNotificationKey() - + ): SignedSubscriptionRequest { val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s val publicKey = swarmAuth.accountId.hexString val sortedNamespace = namespaces.sorted() val signed = swarmAuth.sign( "MONITOR${publicKey}${timestamp}1${sortedNamespace.joinToString(separator = ",")}".encodeToByteArray() ) - val requestParameters = SubscriptionRequest( + + return SubscriptionRequest( pubkey = publicKey, session_ed25519 = swarmAuth.ed25519PublicKeyHex, namespaces = sortedNamespace, @@ -61,41 +71,32 @@ class PushRegistryV2 @Inject constructor( service_info = mapOf("token" to token), enc_key = pnKey.toHexString(), ).let(Json::encodeToJsonElement).jsonObject + signed - - val response = getResponseBody( - "subscribe", - Json.encodeToString(requestParameters) - ) - - check(response.isSuccess()) { - "Error subscribing to push notifications: ${response.message}" - } } suspend fun unregister( + requests: Collection + ): List { + return getResponseBody("unsubscribe", Json.encodeToString(requests)) + } + + fun buildUnregisterRequest( token: String, swarmAuth: SwarmAuth - ) { + ): SignedUnsubscriptionRequest { val publicKey = swarmAuth.accountId.hexString val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s // if we want to support passing namespace list, here is the place to do it - val signature = swarmAuth.signForPushRegistry( + val signature = swarmAuth.sign( "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() ) - val requestParameters = UnsubscriptionRequest( + return UnsubscriptionRequest( pubkey = publicKey, session_ed25519 = swarmAuth.ed25519PublicKeyHex, service = device.service, sig_ts = timestamp, service_info = mapOf("token" to token), ).let(Json::encodeToJsonElement).jsonObject + signature - - val response: UnsubscribeResponse = getResponseBody("unsubscribe", Json.encodeToString(requestParameters)) - - check(response.isSuccess()) { - "Error unsubscribing to push notifications: ${response.message}" - } } private operator fun JsonObject.plus(additional: Map): JsonObject { @@ -108,7 +109,7 @@ class PushRegistryV2 @Inject constructor( } @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun getResponseBody(path: String, requestParameters: String): T { + private suspend inline fun getResponseBody(path: String, requestParameters: String): T { val server = Server.LATEST val url = "${server.url}/$path" val body = requestParameters.toRequestBody("application/json".toMediaType()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index ab436ab890..bb554ba913 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -30,17 +30,26 @@ import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientNamesKt; +import org.session.libsession.utilities.recipients.RemoteFile; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.util.AvatarUtils; -import org.thoughtcrime.securesms.util.BitmapUtil; import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; +import javax.inject.Provider; + +import coil3.BitmapImage; +import coil3.Image; +import coil3.ImageLoader; +import coil3.ImageLoaders; +import coil3.request.CachePolicy; +import coil3.request.ImageRequest; +import coil3.request.ImageResult; import network.loki.messenger.R; public class SingleRecipientNotificationBuilder extends AbstractNotificationBuilder { @@ -53,17 +62,20 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil private CharSequence contentTitle; private CharSequence contentText; private AvatarUtils avatarUtils; + private final Provider imageLoaderProvider; private static final Integer ICON_SIZE = 128; public SingleRecipientNotificationBuilder( @NonNull Context context, @NonNull NotificationPrivacyPreference privacy, - @NonNull AvatarUtils avatarUtils + @NonNull AvatarUtils avatarUtils, + Provider imageLoaderProvider ) { super(context, privacy); this.avatarUtils = avatarUtils; + this.imageLoaderProvider = imageLoaderProvider; setSmallIcon(R.drawable.ic_notification); setColor(ContextCompat.getColor(context, R.color.accent_green)); setCategory(NotificationCompat.CATEGORY_MESSAGE); @@ -72,34 +84,73 @@ public SingleRecipientNotificationBuilder( public void setThread(@NonNull Recipient recipient) { setChannelId(NotificationChannels.getMessagesChannel(context)); + Bitmap largeIconBitmap; + boolean recycleBitmap; + if (privacy.isDisplayContact()) { setContentTitle(RecipientNamesKt.displayName(recipient)); - Object avatar = recipient.getAvatar(); + RemoteFile avatar = recipient.getAvatar(); if (avatar != null) { try { - // AC: For some reason, if not use ".asBitmap()" method, the returned BitmapDrawable - // wraps a recycled bitmap and leads to a crash. - Bitmap iconBitmap = Glide.with(context.getApplicationContext()) - .asBitmap() - .load(avatar) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() - .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), - context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) - .get(); - setLargeIcon(iconBitmap); - } catch (InterruptedException | ExecutionException e) { + int iconWidth = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + int iconHeight = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + + final ImageResult result = ImageLoaders.executeBlocking( + imageLoaderProvider.get(), + new ImageRequest.Builder(context) + .data(avatar) + .size(iconWidth, iconHeight) + .networkCachePolicy(CachePolicy.DISABLED) + .fallback(new BitmapImage(getPlaceholderDrawable(avatarUtils, recipient), true)) + .build() + ); + + Image image = result.getImage(); + + if (image instanceof BitmapImage) { + largeIconBitmap = ((BitmapImage) image).getBitmap(); + recycleBitmap = false; + } else if (image != null) { + // Generate a bitmap from this generic image by drawing on the bitmap + largeIconBitmap = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.RGB_565); + image.draw(new Canvas(largeIconBitmap)); + recycleBitmap = true; + } else { + throw new IllegalStateException("No image returned from Coil"); + } + + } catch (Exception e) { Log.w(TAG, "get iconBitmap in getThread failed", e); - setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); + largeIconBitmap = getPlaceholderDrawable(avatarUtils, recipient); + recycleBitmap = true; } } else { - setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); + largeIconBitmap = getPlaceholderDrawable(avatarUtils, recipient); + recycleBitmap = true; } + setLargeIcon(getCircularBitmap(largeIconBitmap)); + if(recycleBitmap) largeIconBitmap.recycle(); + } else { setContentTitle(context.getString(R.string.app_name)); - setLargeIcon(avatarUtils.generateTextBitmap(ICON_SIZE, "", "Unknown")); + + Drawable drawable = ContextCompat.getDrawable(context, R.drawable.ic_user_filled_custom_padded); + int iconWidth = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + int iconHeight = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + + Bitmap src = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(src); + canvas.drawColor(context.getColor(R.color.classic_dark_3)); + + int padding = (int) (iconWidth * 0.08); //add some padding to the icon + drawable.setBounds(padding, padding, iconWidth - padding, iconHeight - padding); + drawable.draw(canvas); + + setLargeIcon(getCircularBitmap(src)); + setColor(context.getColor(R.color.classic_dark_3)); + src.recycle(); } } @@ -228,18 +279,14 @@ public Notification build() { return super.build(); } - private void setLargeIcon(@Nullable Drawable drawable) { - if (drawable != null) { - int largeIconTargetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); - Bitmap recipientPhotoBitmap = BitmapUtil.createFromDrawable(drawable, largeIconTargetSize, largeIconTargetSize); + private Bitmap getCircularBitmap(Bitmap bitmap) { + boolean recycleInputBitmap = false; - if (recipientPhotoBitmap != null) { - setLargeIcon(getCircularBitmap(recipientPhotoBitmap)); - } + if (bitmap.getConfig() == Bitmap.Config.HARDWARE) { + bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true); + recycleInputBitmap = true; } - } - private Bitmap getCircularBitmap(Bitmap bitmap) { final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(output); final int color = Color.RED; @@ -254,7 +301,9 @@ private Bitmap getCircularBitmap(Bitmap bitmap) { paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); canvas.drawBitmap(bitmap, rect, rect, paint); - bitmap.recycle(); + if (recycleInputBitmap) { + bitmap.recycle(); + } return output; } @@ -314,7 +363,7 @@ private CharSequence getBigText(List messageBodies) { return content; } - private static Drawable getPlaceholderDrawable(AvatarUtils avatarUtils, Recipient recipient) { + private static Bitmap getPlaceholderDrawable(AvatarUtils avatarUtils, Recipient recipient) { String publicKey = recipient.getAddress().toString(); String displayName = RecipientNamesKt.displayName(recipient); return avatarUtils.generateTextBitmap(ICON_SIZE, publicKey, displayName); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt deleted file mode 100644 index 6f0998eecb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import androidx.preference.ListPreference -import org.thoughtcrime.securesms.showSessionDialog - -fun listPreferenceDialog( - context: Context, - listPreference: ListPreference, - onChange: () -> Unit -) : AlertDialog = listPreference.run { - context.showSessionDialog { - val index = entryValues.indexOf(value) - val options = entries.map(CharSequence::toString).toTypedArray() - - title(dialogTitle) - text(dialogMessage) - singleChoiceItems(options, index) { - listPreference.setValueIndex(it) - onChange() - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt deleted file mode 100644 index 1e0bcd27c0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import network.loki.messenger.R -import network.loki.messenger.databinding.ItemSelectableBinding -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.ui.GetString -import java.util.Objects - -class RadioOptionAdapter( - private var selectedOptionPosition: Int = 0, - private val onClickListener: (RadioOption) -> Unit -) : ListAdapter, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { - - class RadioOptionDiffer: DiffUtil.ItemCallback>() { - override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = Objects.equals(oldItem.value,newItem.value) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = - LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) - .let(::ViewHolder) - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind( - option = getItem(position), - isSelected = position == selectedOptionPosition - ) { - onClickListener(it) - setSelectedPosition(position) - } - } - - fun setSelectedPosition(selectedPosition: Int) { - notifyItemChanged(selectedOptionPosition) - selectedOptionPosition = selectedPosition - notifyItemChanged(selectedOptionPosition) - } - - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { - val glide = Glide.with(itemView) - val binding = ItemSelectableBinding.bind(itemView) - - fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { - val alpha = if (option.enabled) 1f else 0.5f - binding.root.isEnabled = option.enabled - binding.root.contentDescription = option.contentDescription?.string(itemView.context) - binding.titleTextView.alpha = alpha - binding.subtitleTextView.alpha = alpha - binding.selectButton.alpha = alpha - - binding.titleTextView.text = option.title.string(itemView.context) - binding.subtitleTextView.text = option.subtitle?.string(itemView.context).also { - binding.subtitleTextView.isVisible = !it.isNullOrBlank() - } - - binding.selectButton.isSelected = isSelected - if (option.enabled) { - binding.root.setOnClickListener { toggleSelection(option) } - } - } - } -} - -data class RadioOption( - val value: T, - val title: GetString, - val subtitle: GetString? = null, - val enabled: Boolean = true, - val contentDescription: GetString? = null -) - -fun radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder.() -> Unit = {}) = - radioOption(value, GetString(title), configure) - -fun radioOption(value: T, title: String, configure: RadioOptionBuilder.() -> Unit = {}) = - radioOption(value, GetString(title), configure) - -fun radioOption(value: T, title: GetString, configure: RadioOptionBuilder.() -> Unit = {}) = - RadioOptionBuilder(value, title).also { it.configure() }.build() - -class RadioOptionBuilder( - val value: T, - val title: GetString -) { - var subtitle: GetString? = null - var enabled: Boolean = true - var contentDescription: GetString? = null - - fun subtitle(string: String) { - subtitle = GetString(string) - } - - fun subtitle(@StringRes stringRes: Int) { - subtitle = GetString(stringRes) - } - - fun contentDescription(string: String) { - contentDescription = GetString(string) - } - - fun contentDescription(@StringRes stringRes: Int) { - contentDescription = GetString(stringRes) - } - - fun build() = RadioOption( - value, - title, - subtitle, - enabled, - contentDescription - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 8af4fc620a..eaa28dd05d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -2,6 +2,7 @@ import android.Manifest import android.content.ActivityNotFoundException +import android.graphics.Bitmap import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -10,14 +11,20 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.canhub.cropper.CropImageContract import com.canhub.cropper.CropImageContractOptions import com.canhub.cropper.CropImageOptions import com.canhub.cropper.CropImageView import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.FullComposeScreenLockActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.FileProviderUtil @@ -59,7 +66,12 @@ class SettingsActivity : FullComposeScreenLockActivity() { viewModel.hideAvatarPickerOptions() // close the bottom sheet val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - cropImage(viewModel.getTempFile()?.let(Uri::fromFile), outputFile) + val inputFile = viewModel.getTempFile()?.let(Uri::fromFile) + if (inputFile == null) { + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + cropImage(inputFile, outputFile) } else { Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() } @@ -118,30 +130,47 @@ class SettingsActivity : FullComposeScreenLockActivity() { .execute() } - private fun cropImage(inputFile: Uri?, outputFile: Uri?){ - onAvatarCropped.launch( - CropImageContractOptions( - uri = inputFile, - cropImageOptions = CropImageOptions( - guidelines = CropImageView.Guidelines.ON, - aspectRatioX = 1, - aspectRatioY = 1, - fixAspectRatio = true, - cropShape = CropImageView.CropShape.OVAL, - customOutputUri = outputFile, - allowRotation = true, - allowFlipping = true, - backgroundColor = imageScrim, - toolbarColor = bgColor, - activityBackgroundColor = bgColor, - toolbarTintColor = txtColor, - toolbarBackButtonColor = txtColor, - toolbarTitleColor = txtColor, - activityMenuIconColor = txtColor, - activityMenuTextColor = txtColor, - activityTitle = activityTitle + private fun cropImage(inputFile: Uri, outputFile: Uri){ + lifecycleScope.launch { + try { + val inputType = withContext(Dispatchers.Default) { + contentResolver.getType(inputFile) + } + + onAvatarCropped.launch( + CropImageContractOptions( + uri = inputFile, + cropImageOptions = CropImageOptions( + guidelines = CropImageView.Guidelines.ON, + aspectRatioX = 1, + aspectRatioY = 1, + fixAspectRatio = true, + cropShape = CropImageView.CropShape.OVAL, + customOutputUri = outputFile, + allowRotation = true, + allowFlipping = true, + backgroundColor = imageScrim, + toolbarColor = bgColor, + activityBackgroundColor = bgColor, + toolbarTintColor = txtColor, + toolbarBackButtonColor = txtColor, + toolbarTitleColor = txtColor, + activityMenuIconColor = txtColor, + activityMenuTextColor = txtColor, + activityTitle = activityTitle, + outputCompressFormat = when { + inputType?.startsWith("image/png") == true -> Bitmap.CompressFormat.PNG + inputType?.startsWith("image/webp") == true -> Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } + ) + ) ) - ) - ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Error launching cropper", e) + Toast.makeText(this@SettingsActivity, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index ddbbb89eb0..ed138aab7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.preferences import android.content.Context import android.net.Uri +import android.provider.OpenableColumns import android.widget.Toast +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.canhub.cropper.CropImage @@ -10,6 +12,7 @@ import com.canhub.cropper.CropImageView import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,6 +29,9 @@ import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.UserPic +import okio.blackholeSink +import okio.buffer +import okio.source import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.open_groups.OpenGroupApi @@ -42,22 +48,21 @@ import org.session.libsession.utilities.recipients.isPro import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.NoExternalStorageException +import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.attachments.AvatarUploadManager import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData -import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints import org.thoughtcrime.securesms.reviews.InAppReviewManager import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.util.AnimatedImageUtils import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils -import org.thoughtcrime.securesms.util.BitmapDecodingException -import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ClearDataUtils import org.thoughtcrime.securesms.util.NetworkConnectivity import org.thoughtcrime.securesms.util.State @@ -80,6 +85,7 @@ class SettingsViewModel @Inject constructor( private val storage: StorageProtocol, private val inAppReviewManager: InAppReviewManager, private val avatarUploadManager: AvatarUploadManager, + private val attachmentProcessor: AttachmentProcessor, ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -181,29 +187,7 @@ class SettingsViewModel @Inject constructor( fun onAvatarPicked(result: CropImageView.CropResult) { when { result.isSuccessful -> { - Log.i(TAG, result.getUriFilePath(context).toString()) - - viewModelScope.launch(Dispatchers.IO) { - try { - val profilePictureToBeUploaded = - BitmapUtil.createScaledBytes( - context, - result.getUriFilePath(context).toString(), - ProfileMediaConstraints() - ).bitmap - - // update dialog with temporary avatar (has not been saved/uploaded yet) - _uiState.update { - it.copy(avatarDialogState = AvatarDialogState.TempAvatar( - data = profilePictureToBeUploaded, - isAnimated = false, // cropped avatars can't be animated - hasAvatar = hasAvatar() - )) - } - } catch (e: BitmapDecodingException) { - Log.e(TAG, e) - } - } + onAvatarPicked("file://${result.getUriFilePath(context)!!}".toUri()) } result is CropImage.CancelledResult -> { @@ -219,26 +203,49 @@ class SettingsViewModel @Inject constructor( fun onAvatarPicked(uri: Uri) { Log.i(TAG, "Picked a new avatar: $uri") - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { try { - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + // Query the content resolver for the size of this image + val contentSize = withContext(Dispatchers.IO) { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.getLong(sizeIndex) + } else { + null + } + } + ?: context.contentResolver.openInputStream(uri)!!.source().buffer().use { it.readAll(blackholeSink()) } + } - if(bytes == null){ - Log.e(TAG, "Error reading avatar bytes") - Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() - } else { - _uiState.update { - it.copy( - avatarDialogState = AvatarDialogState.TempAvatar( - data = bytes, - isAnimated = isAnimated(uri), - hasAvatar = hasAvatar() - ) - ) + if (contentSize > MediaConstraints.getPushMediaConstraints().getImageMaxSize(context).toLong()) { + Log.e(TAG, "Selected avatar image is too large: $contentSize bytes") + Toast.makeText(context, R.string.profileDisplayPictureSizeError, Toast.LENGTH_LONG).show() + return@launch + } + + + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)!!.use { + it.readBytes() } } + + _uiState.update { + it.copy( + avatarDialogState = AvatarDialogState.TempAvatar( + data = bytes, + isAnimated = isAnimated(bytes), + hasAvatar = hasAvatar() + ) + ) + } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e(TAG, "Error reading avatar bytes", e) + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG) + .show() } } } @@ -328,7 +335,11 @@ class SettingsViewModel @Inject constructor( // update dialog state _uiState.update { it.copy(avatarDialogState = AvatarDialogState.NoAvatar) } } else { - avatarUploadManager.uploadAvatar(profilePicture, isReupload = false) + val processed = withContext(Dispatchers.Default) { + attachmentProcessor.processAvatar(profilePicture) + }?.data ?: profilePicture + + avatarUploadManager.uploadAvatar(processed, isReupload = false) // We'll have to refetch the recipient to get the new avatar val selfRecipient = recipientRepository.getSelf() @@ -352,6 +363,9 @@ class SettingsViewModel @Inject constructor( fun isAnimated(uri: Uri) = proStatusManager.isPostPro() // block animated avatars prior to pro && AnimatedImageUtils.isAnimated(context, uri) + fun isAnimated(rawImageData: ByteArray) = proStatusManager.isPostPro() // block animated avatars prior to pro + && AnimatedImageUtils.isAnimated(rawImageData) + private fun showAnimatedProCTA() { _uiState.update { it.copy(showAnimatedProCTA = true) } } @@ -440,7 +454,7 @@ class SettingsViewModel @Inject constructor( coroutineScope { allCommunityServers.map { server -> launch { - runCatching { OpenGroupApi.deleteAllInboxMessages(server).await() } + runCatching { OpenGroupApi.deleteAllInboxMessages(server) } .onFailure { Log.e(TAG, "Error deleting messages for $server", it) } } }.joinAll() diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java deleted file mode 100644 index c456d80eeb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.profiles; - - -import android.content.Context; - -import org.thoughtcrime.securesms.mms.MediaConstraints; - -public class ProfileMediaConstraints extends MediaConstraints { - @Override - public int getImageMaxWidth(Context context) { - return 640; - } - - @Override - public int getImageMaxHeight(Context context) { - return 640; - } - - @Override - public int getImageMaxSize(Context context) { - return 5 * 1024 * 1024; - } - - @Override - public int getGifMaxSize(Context context) { - return 0; - } - - @Override - public int getVideoMaxSize(Context context) { - return 0; - } - - @Override - public int getAudioMaxSize(Context context) { - return 0; - } - - @Override - public int getDocumentMaxSize(Context context) { - return 0; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt index f97daa9319..98a9a9a56f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -23,9 +23,9 @@ import org.thoughtcrime.securesms.crypto.MnemonicUtilities @HiltViewModel class RecoveryPasswordViewModel @Inject constructor( - private val application: Application + private val application: Application, + private val prefs: TextSecurePreferences, ): AndroidViewModel(application) { - val prefs = AppTextSecurePreferences(application) val seed = MutableStateFlow(null) val mnemonic = seed.filterNotNull() diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 6d94fc83f7..56b8d0bef7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -386,7 +386,7 @@ class DefaultConversationRepository @Inject constructor( ) { messages.forEach { message -> lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, community.room, community.serverUrl).await() + OpenGroupApi.deleteMessage(messageServerID, community.room, community.serverUrl) } } } @@ -484,7 +484,7 @@ class DefaultConversationRepository @Inject constructor( publicKey = userId.hexString, room = community.room, server = community.serverUrl, - ).await() + ) } override suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId) = runCatching { @@ -493,7 +493,7 @@ class DefaultConversationRepository @Inject constructor( publicKey = userId.hexString, room = community.room, server = community.serverUrl - ).await() + ) } override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java deleted file mode 100644 index f24906c5e1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.service; - - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.session.libsignal.utilities.Log; - -public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { - - private static final String TAG = PersistentAlarmManagerListener.class.getSimpleName(); - - protected abstract long getNextScheduledExecutionTime(Context context); - - protected abstract long onAlarm(Context context, long scheduledTime); - - @Override - public void onReceive(Context context, Intent intent) { - long scheduledTime = getNextScheduledExecutionTime(context); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent alarmIntent = new Intent(context, getClass()); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_IMMUTABLE); - - if (System.currentTimeMillis() >= scheduledTime) { - scheduledTime = onAlarm(context, scheduledTime); - } - - Log.i(TAG, getClass() + " scheduling for: " + scheduledTime); - - alarmManager.cancel(pendingIntent); - alarmManager.set(AlarmManager.RTC_WAKEUP, scheduledTime, pendingIntent); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java deleted file mode 100644 index 09803bb793..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.app.IntentService; -import android.content.Context; -import android.content.Intent; -import android.telephony.TelephonyManager; -import android.text.TextUtils; -import android.widget.Toast; -import com.squareup.phrase.Phrase; -import java.net.URISyntaxException; -import java.net.URLDecoder; -import network.loki.messenger.R; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.util.Rfc5724Uri; - -public class QuickResponseService extends IntentService { - - private static final String TAG = QuickResponseService.class.getSimpleName(); - - public QuickResponseService() { - super("QuickResponseService"); - } - - @Override - protected void onHandleIntent(Intent intent) { - if (intent == null) { - Log.w(TAG, "Got null intent from QuickResponseService"); - return; - } - - String actionString = intent.getAction(); - if (actionString == null) { - Log.w(TAG, "Got null action from QuickResponseService intent"); - return; - } - - if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(actionString)) { - Log.w(TAG, "Received unknown intent: " + intent.getAction()); - return; - } - - if (KeyCachingService.isLocked(this)) { - Log.w(TAG, "Got quick response request when locked..."); - Context c = getApplicationContext(); - String txt = Phrase.from(c, R.string.lockAppQuickResponse) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - Toast.makeText(this, txt, Toast.LENGTH_LONG).show(); - return; - } - - try { - Rfc5724Uri uri = new Rfc5724Uri(intent.getDataString()); - String content = intent.getStringExtra(Intent.EXTRA_TEXT); - String number = uri.getPath(); - - if (number.contains("%")){ - number = URLDecoder.decode(number); - } - - if (!TextUtils.isEmpty(content)) { - VisibleMessage message = new VisibleMessage(); - message.setText(content); - message.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(message, Address.fromSerialized(number)); - } - } catch (URISyntaxException e) { - Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_LONG).show(); - Log.w(TAG, e); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java deleted file mode 100644 index 85d00ec8e5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ /dev/null @@ -1,126 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.app.DownloadManager; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import com.squareup.phrase.Phrase; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; -import network.loki.messenger.R; -import org.session.libsession.utilities.FileUtils; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Hex; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.util.FileProviderUtil; - -public class UpdateApkReadyListener extends BroadcastReceiver { - - private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "onReceive()"); - - if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { - long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2); - - if (downloadId == TextSecurePreferences.getUpdateApkDownloadId(context)) { - Uri uri = getLocalUriForDownloadId(context, downloadId); - String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); - - if (uri == null) { - Log.w(TAG, "Downloaded local URI is null?"); - return; - } - - if (isMatchingDigest(context, downloadId, encodedDigest)) { - displayInstallNotification(context, uri); - } else { - Log.w(TAG, "Downloaded APK doesn't match digest..."); - } - } - } - } - - private void displayInstallNotification(Context context, Uri uri) { - Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE); - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.setData(uri); - - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); - - CharSequence title = Phrase.from(context, R.string.updateSession) - .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); - - CharSequence txt = Phrase.from(context, R.string.updateNewVersion) - .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); - - - Notification notification = new NotificationCompat.Builder(context, NotificationChannels.APP_UPDATES) - .setOngoing(true) - .setContentTitle(title) - .setContentText(txt) - .setSmallIcon(R.drawable.ic_notification) - .setColor(context.getResources().getColor(R.color.textsecure_primary)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_REMINDER) - .setContentIntent(pendingIntent) - .build(); - - ServiceUtil.getNotificationManager(context).notify(666, notification); - } - - private @Nullable Uri getLocalUriForDownloadId(Context context, long downloadId) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Query query = new DownloadManager.Query(); - query.setFilterById(downloadId); - - Cursor cursor = downloadManager.query(query); - - try { - if (cursor != null && cursor.moveToFirst()) { - String localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); - - if (localUri != null) { - File localFile = new File(Uri.parse(localUri).getPath()); - return FileProviderUtil.getUriFor(context, localFile); - } - } - } finally { - if (cursor != null) cursor.close(); - } - - return null; - } - - private boolean isMatchingDigest(Context context, long downloadId, String theirEncodedDigest) { - try { - if (theirEncodedDigest == null) return false; - - byte[] theirDigest = Hex.fromStringCondensed(theirEncodedDigest); - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); - byte[] ourDigest = FileUtils.getFileDigest(fin); - - fin.close(); - - return MessageDigest.isEqual(ourDigest, theirDigest); - } catch (IOException e) { - Log.w(TAG, e); - return false; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt index 01832b6438..47ecd00418 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt @@ -34,8 +34,11 @@ fun DropDown( modifier = modifier, selected = selectedText, values = values, - onValueSelected = onValueSelected, - labeler = { it.orEmpty() } + onValueSelected = { + onValueSelected(it!!) + }, + labeler = { it.orEmpty() }, + allowSelectingNullValue = false, ) } @@ -45,8 +48,9 @@ fun DropDown( modifier: Modifier = Modifier, selected: T?, values: List, - onValueSelected: (T) -> Unit, + onValueSelected: (T?) -> Unit, labeler: (T?) -> String, + allowSelectingNullValue: Boolean ) { var expanded by remember { mutableStateOf(false) } @@ -93,7 +97,8 @@ fun DropDown( expanded = expanded, onDismissRequest = { expanded = false } ) { - values.forEach { item -> + (if (allowSelectingNullValue) (listOf(null) + values) else values) + .forEach { item -> DropdownMenuItem( text = { Text( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index 12921f5f72..8a2b3e26bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -39,21 +39,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException +import com.google.zxing.DecodeHintType import com.google.zxing.FormatException import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource -import com.google.zxing.Result +import com.google.zxing.common.GlobalHistogramBinarizer import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader import com.squareup.phrase.Phrase @@ -255,38 +257,100 @@ class QRCodeAnalyzer( private val onBarcodeScanned: (String) -> Unit ): ImageAnalysis.Analyzer { - // Note: This analyze method is called once per frame of the camera feed. @SuppressLint("UnsafeOptInUsageError") override fun analyze(image: ImageProxy) { - // Grab the image data as a byte array so we can generate a PlanarYUVLuminanceSource from it - val buffer = image.planes[0].buffer - buffer.rewind() - val imageBytes = ByteArray(buffer.capacity()) - buffer.get(imageBytes) // IMPORTANT: This transfers data from the buffer INTO the imageBytes array, although it looks like it would go the other way around! + try { + // Visible frame size that ZXing will use for decoding + val w = image.width + val h = image.height - // ZXing requires data as a BinaryBitmap to scan for QR codes, and to generate that we need to feed it a PlanarYUVLuminanceSource - val luminanceSource = PlanarYUVLuminanceSource(imageBytes, image.width, image.height, 0, 0, image.width, image.height, false) - val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + // YUV_420_888 format: plane[0] = Y (grayscale), plane[1] = U, plane[2] = V + // ZXing only needs luminance (Y) + val yPlane = image.planes[0] - // Attempt to extract a QR code from the binary bitmap, and pass it through to our `onBarcodeScanned` method if we find one - try { - val result: Result = qrCodeReader.decode(binaryBitmap) - val resultTxt = result.text - // No need to close the image here - it'll always make it to the end, and calling `onBarcodeScanned` - // with a valid contact / recovery phrase / community code will stop calling this `analyze` method. - onBarcodeScanned(resultTxt) - } - catch (nfe: NotFoundException) { /* Hits if there is no QR code in the image */ } - catch (fe: FormatException) { /* Hits if we found a QR code but failed to decode it */ } - catch (ce: ChecksumException) { /* Hits if we found a QR code which is corrupted */ } - catch (e: Exception) { - // Hits if there's a genuine problem - Log.e("QR", "error", e) - } + // Strides describe how bytes are laid out in memory for this plane + // - rowStride: distance in bytes from start of one row to the next row + // - pixelStride: distance in bytes from one pixel to the next pixel in the same row + // Usually 1 for Y (packed), but not guaranteed across devices + val rowStride = yPlane.rowStride + val pixelStride = yPlane.pixelStride + + val buf = yPlane.buffer + buf.rewind() + + // ZXing wants a contiguous WxH grayscale buffer (one byte per pixel) + val y = ByteArray(w * h) + + // FAST PATH: already tightly packed (no row padding, no interleaving) + if (pixelStride == 1 && rowStride == w) { + // We can copy the entire Y plane in a single read + buf.get(y, 0, y.size) + } else { + // GENERAL PATH: re-pack into contiguous WxH + // We use a duplicate buffer so we can manipulate position/absolute reads + // without affecting the original buffer state elsewhere + val dup = buf.duplicate() + + var dst = 0 // index we write into in the output array 'y' + + // Walk row by row in the source plane + for (row in 0 until h) { + // Start of this row in the plane's buffer + val rowStart = row * rowStride + + if (pixelStride == 1) { + // Case A: packed pixels (good), but rows have padding (rowStride > w) + // Copy only the first 'w' bytes of each row into our contiguous output + dup.position(rowStart) + dup.get(y, dst, w) + dst += w + } else { + // Case B: pixels are interleaved horizontally (pixelStride > 1) + // Read one luminance byte every 'pixelStride' bytes for 'w' columns + for (col in 0 until w) { + // Absolute read: get byte at (rowStart + col*pixelStride) without + // changing buffer's position. This picks each pixel's Y byte + y[dst++] = dup.get(rowStart + col * pixelStride) + } + } + } + } + + // Build a source from a contiguous Y plane (no rotation) + val base = PlanarYUVLuminanceSource( + y, w, h, 0, 0, w, h, false + ) + + val hints = java.util.EnumMap(DecodeHintType::class.java).apply { + put(DecodeHintType.TRY_HARDER, true) + put(DecodeHintType.POSSIBLE_FORMATS, listOf(BarcodeFormat.QR_CODE)) + } - // Remember to close the image when we're done with it! - // IMPORTANT: It is CLOSING the image that allows this method to run again! If we don't - // close the image this method runs precisely ONCE and that's it, which is essentially useless. - image.close() + val attempts = listOf( + BinaryBitmap(HybridBinarizer(base)), + BinaryBitmap(GlobalHistogramBinarizer(base)), + BinaryBitmap(HybridBinarizer(com.google.zxing.InvertedLuminanceSource(base))), + BinaryBitmap(GlobalHistogramBinarizer(com.google.zxing.InvertedLuminanceSource(base))) + ) + + for (bb in attempts) { + try { + val result = qrCodeReader.decode(bb, hints) + onBarcodeScanned(result.text) + return + } catch (_: NotFoundException) { + qrCodeReader.reset() // harmless, move to next attempt + } + } + } catch (e: FormatException) { + Log.e("QR", "QR decoding failed", e) + } catch (e: ChecksumException) { + Log.e("QR", "QR checksum exception", e) + } catch (e: Exception) { + Log.e("QR", "Analyzer error", e) + } finally { + qrCodeReader.reset() + image.close() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 6c1395d1be..aa2ef6f7cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -19,8 +20,8 @@ import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext // Globally accessible composition local objects -val LocalColors = compositionLocalOf { ClassicDark() } -val LocalType = compositionLocalOf { sessionTypography } +val LocalColors = staticCompositionLocalOf { ClassicDark() } +val LocalType = staticCompositionLocalOf { sessionTypography } var cachedColorsProvider: ThemeColorsProvider? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt index 3a1c0776db..61cc78cd5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt @@ -1,53 +1,46 @@ package org.thoughtcrime.securesms.util import android.content.Context -import android.graphics.ImageDecoder import android.net.Uri -import android.os.Build +import coil3.decode.DecodeUtils +import coil3.gif.isAnimatedWebP +import network.loki.messenger.libsession_util.image.GifUtils +import okio.BufferedSource +import okio.buffer +import okio.source +import java.io.ByteArrayInputStream /** * A class offering helper methods relating to animated images */ object AnimatedImageUtils { fun isAnimated(context: Context, uri: Uri): Boolean { - val mime = context.contentResolver.getType(uri) - return when (mime) { - "image/gif" -> isAnimatedImage(context, uri) // not all gifs are animated - "image/webp" -> isAnimatedImage(context, uri) // not all WebPs are animated - else -> false - } + return context.contentResolver.openInputStream(uri)?.source()?.buffer()?.use { + isAnimatedGif(it) || isAnimatedWebP(it) + } == true } - private fun isAnimatedImage(context: Context, uri: Uri): Boolean { - if (Build.VERSION.SDK_INT < 28) return isAnimatedImageLegacy(context, uri) - - var animated = false - val source = ImageDecoder.createSource(context.contentResolver, uri) - - ImageDecoder.decodeDrawable(source) { _, info, _ -> - animated = info.isAnimated // true for GIF & animated WebP + fun isAnimated(rawImageData: ByteArray): Boolean { + if (isAnimatedGif(rawImageData)) { + return true } - return animated + return ByteArrayInputStream(rawImageData).source().buffer().use { + isAnimatedWebP(it) + } } - private fun isAnimatedImageLegacy(context: Context, uri: Uri): Boolean { - context.contentResolver.openInputStream(uri)?.use { input -> - val header = ByteArray(32) - if (input.read(header) != header.size) return false + /** + * Returns true if the provided buffer contains an animated WebP image. The buffer is not consumed. + */ + fun isAnimatedWebP(buffer: BufferedSource): Boolean = DecodeUtils.isAnimatedWebP(buffer) - // Bytes 12-15 contain “VP8X” - val isVp8x = header.sliceArray(12..15).contentEquals("VP8X".toByteArray()) - - if (isVp8x) { - /* 21st byte (index 20) holds the feature flags; bit #1 = animation */ - val animationFlagSet = header[21].toInt() and 0x02 != 0 - if (animationFlagSet) return true // animated! - } - - // Fallback scan for literal “ANIM” in header area - return header.asList().windowed(4).any { it.toByteArray().contentEquals("ANIM".toByteArray()) } - } - return false + /** + * Returns true if the provided buffer contains an animated GIF image. The buffer is not consumed. + */ + fun isAnimatedGif(buffer: BufferedSource): Boolean { + return buffer.peek().inputStream().use(GifUtils::isAnimatedGif) } + + fun isAnimatedGif(buffer: ByteArray): Boolean = GifUtils.isAnimatedGif(buffer) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt index db69bd8cc4..a978a65e39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect @@ -28,6 +29,7 @@ import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.theme.classicDark3 import java.math.BigInteger import java.security.MessageDigest import java.util.Locale @@ -101,7 +103,13 @@ class AvatarUtils @Inject constructor( // custom image val (remoteFile, customIcon, color) = when { // use custom image if there is one - recipient.avatar != null -> Triple(recipient.avatar!!, null, defaultColor) + recipient.avatar != null -> Triple( + recipient.avatar!!, + // for communities, have an icon fallback in case the image errors out + if(recipient.isCommunityRecipient) R.drawable.session_logo else null, + // communities should always have a neutral fallback bg + if(recipient.isCommunityRecipient) classicDark3 else defaultColor + ) // communities without a custom image should use a default image recipient.isCommunityRecipient -> Triple(null, R.drawable.session_logo, null) @@ -128,7 +136,7 @@ class AvatarUtils @Inject constructor( return avatarBgColors[(hash % avatarBgColors.size).toInt()] } - fun generateTextBitmap(pixelSize: Int, hashString: String, displayName: String?): BitmapDrawable { + fun generateTextBitmap(pixelSize: Int, hashString: String, displayName: String?): Bitmap { val colorPrimary = getColorFromKey(hashString) val labelText = when { @@ -158,7 +166,7 @@ class AvatarUtils @Inject constructor( textBounds.top += (areaRect.height() - textBounds.bottom) * 0.5f canvas.drawText(labelText, textBounds.left, textBounds.top - textPaint.ascent(), textPaint) - return BitmapDrawable(context.resources, bitmap) + return bitmap } private fun getSha512(input: String): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index 5e1a85df51..fe9fc78836 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -1,37 +1,24 @@ package org.thoughtcrime.securesms.util; -import android.content.Context; import android.graphics.Bitmap; import android.graphics.Bitmap.CompressFormat; import android.graphics.BitmapFactory; import android.graphics.Canvas; -import android.graphics.ImageFormat; -import android.graphics.Rect; -import android.graphics.YuvImage; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.exifinterface.media.ExifInterface; import android.util.Pair; -import com.bumptech.glide.load.engine.DiskCacheStrategy; - -import org.session.libsession.utilities.MediaTypes; -import org.session.libsignal.utilities.Log; -import com.bumptech.glide.Glide; -import org.thoughtcrime.securesms.mms.MediaConstraints; +import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; import org.session.libsession.utilities.Util; +import org.session.libsignal.utilities.Log; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.Locale; -import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import javax.microedition.khronos.egl.EGL10; @@ -43,176 +30,6 @@ public class BitmapUtil { private static final String TAG = BitmapUtil.class.getSimpleName(); - private static final int MAX_COMPRESSION_QUALITY = 95; - private static final int MIN_COMPRESSION_QUALITY = 45; - private static final int MAX_COMPRESSION_ATTEMPTS = 5; - private static final int MIN_COMPRESSION_QUALITY_DECREASE = 5; - private static final int MAX_IMAGE_HALF_SCALES = 3; - - @WorkerThread - public static ScaleResult createScaledBytes(@NonNull Context context, @NonNull T model, @NonNull MediaConstraints constraints) - throws BitmapDecodingException - { - return createScaledBytes(context, model, - constraints.getImageMaxWidth(context), - constraints.getImageMaxHeight(context), - constraints.getImageMaxSize(context)); - } - - @WorkerThread - public static ScaleResult createScaledBytes(@NonNull Context context, - @NonNull T model, - final int maxImageWidth, - final int maxImageHeight, - final int maxImageSize) - throws BitmapDecodingException - { - return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, CompressFormat.JPEG); - } - - @WorkerThread - public static ScaleResult createScaledBytes(Context context, - T model, - int maxImageWidth, - int maxImageHeight, - int maxImageSize, - @NonNull CompressFormat format) - throws BitmapDecodingException - { - return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, format, 1, 0); - } - - @WorkerThread - private static ScaleResult createScaledBytes(@NonNull Context context, - @NonNull T model, - final int maxImageWidth, - final int maxImageHeight, - final int maxImageSize, - @NonNull CompressFormat format, - final int sizeAttempt, - int totalAttempts) - throws BitmapDecodingException - { - try { - int quality = MAX_COMPRESSION_QUALITY; - int attempts = 0; - byte[] bytes; - - Bitmap scaledBitmap = Glide.with(context.getApplicationContext()) - .asBitmap() - .load(model) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .centerInside() - .submit(maxImageWidth, maxImageHeight) - .get(); - - if (scaledBitmap == null) { - throw new BitmapDecodingException("Unable to decode image"); - } - - Log.i(TAG, String.format(Locale.US,"Initial scaled bitmap has size of %d bytes.", scaledBitmap.getByteCount())); - Log.i(TAG, String.format(Locale.US, "Max dimensions %d x %d, %d bytes", maxImageWidth, maxImageHeight, maxImageSize)); - - try { - do { - totalAttempts++; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - if (!scaledBitmap.compress(format, quality, baos)) { - Log.d(TAG, "Unable to compress image with quality " + quality); - } - bytes = baos.toByteArray(); - - Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes."); - if (quality == MIN_COMPRESSION_QUALITY) break; - - int nextQuality = (int)Math.floor(quality * Math.sqrt((double)maxImageSize / bytes.length)); - if (quality - nextQuality < MIN_COMPRESSION_QUALITY_DECREASE) { - nextQuality = quality - MIN_COMPRESSION_QUALITY_DECREASE; - } - quality = Math.max(nextQuality, MIN_COMPRESSION_QUALITY); - } - while (bytes.length > maxImageSize && attempts++ < MAX_COMPRESSION_ATTEMPTS); - - if (bytes.length > maxImageSize) { - if (sizeAttempt <= MAX_IMAGE_HALF_SCALES) { - scaledBitmap.recycle(); - scaledBitmap = null; - - Log.i(TAG, "Halving dimensions and retrying."); - return createScaledBytes(context, model, maxImageWidth / 2, maxImageHeight / 2, maxImageSize, format, sizeAttempt + 1, totalAttempts); - } else { - throw new BitmapDecodingException("Unable to scale image below " + bytes.length + " bytes."); - } - } - - if (bytes.length == 0) { - throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes."); - } - - Log.i(TAG, String.format(Locale.US, "createScaledBytes(%s) -> quality %d, %d attempt(s) over %d sizes.", model.getClass().getName(), quality, totalAttempts, sizeAttempt)); - - return new ScaleResult(bytes, scaledBitmap.getWidth(), scaledBitmap.getHeight()); - } finally { - if (scaledBitmap != null) scaledBitmap.recycle(); - } - } catch (InterruptedException | ExecutionException e) { - throw new BitmapDecodingException(e); - } - } - - @WorkerThread - public static Bitmap createScaledBitmap(Context context, T model, int maxWidth, int maxHeight) - throws BitmapDecodingException - { - try { - return Glide.with(context.getApplicationContext()) - .asBitmap() - .load(model) - .centerInside() - .submit(maxWidth, maxHeight) - .get(); - } catch (InterruptedException | ExecutionException e) { - throw new BitmapDecodingException(e); - } - } - - @WorkerThread - public static Bitmap createScaledBitmap(Bitmap bitmap, int maxWidth, int maxHeight) { - if (bitmap.getWidth() <= maxWidth && bitmap.getHeight() <= maxHeight) { - return bitmap; - } - - if (maxWidth <= 0 || maxHeight <= 0) { - return bitmap; - } - - int newWidth = maxWidth; - int newHeight = maxHeight; - - float widthRatio = bitmap.getWidth() / (float) maxWidth; - float heightRatio = bitmap.getHeight() / (float) maxHeight; - - if (widthRatio > heightRatio) { - newHeight = (int) (bitmap.getHeight() / widthRatio); - } else { - newWidth = (int) (bitmap.getWidth() / heightRatio); - } - - return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); - } - - public static @NonNull CompressFormat getCompressFormatForContentType(@Nullable String contentType) { - if (contentType == null) return CompressFormat.JPEG; - - switch (contentType) { - case MediaTypes.IMAGE_JPEG: return CompressFormat.JPEG; - case MediaTypes.IMAGE_PNG: return CompressFormat.PNG; - case MediaTypes.IMAGE_WEBP: return CompressFormat.WEBP; - default: return CompressFormat.JPEG; - } - } - private static BitmapFactory.Options getImageDimensions(InputStream inputStream) throws BitmapDecodingException { @@ -271,86 +88,6 @@ public static InputStream toCompressedJpeg(Bitmap bitmap) { return stream.toByteArray(); } - public static @Nullable Bitmap fromByteArray(@Nullable byte[] bytes) { - if (bytes == null) return null; - return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - } - - public static byte[] createFromNV21(@NonNull final byte[] data, - final int width, - final int height, - int rotation, - final Rect croppingRect, - final boolean flipHorizontal) - throws IOException - { - byte[] rotated = rotateNV21(data, width, height, rotation, flipHorizontal); - final int rotatedWidth = rotation % 180 > 0 ? height : width; - final int rotatedHeight = rotation % 180 > 0 ? width : height; - YuvImage previewImage = new YuvImage(rotated, ImageFormat.NV21, - rotatedWidth, rotatedHeight, null); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - previewImage.compressToJpeg(croppingRect, 80, outputStream); - byte[] bytes = outputStream.toByteArray(); - outputStream.close(); - return bytes; - } - - /* - * NV21 a.k.a. YUV420sp - * YUV 4:2:0 planar image, with 8 bit Y samples, followed by interleaved V/U plane with 8bit 2x2 - * subsampled chroma samples. - * - * http://www.fourcc.org/yuv.php#NV21 - */ - public static byte[] rotateNV21(@NonNull final byte[] yuv, - final int width, - final int height, - final int rotation, - final boolean flipHorizontal) - throws IOException - { - if (rotation == 0) return yuv; - if (rotation % 90 != 0 || rotation < 0 || rotation > 270) { - throw new IllegalArgumentException("0 <= rotation < 360, rotation % 90 == 0"); - } else if ((width * height * 3) / 2 != yuv.length) { - throw new IOException("provided width and height don't jive with the data length (" + - yuv.length + "). Width: " + width + " height: " + height + - " = data length: " + (width * height * 3) / 2); - } - - final byte[] output = new byte[yuv.length]; - final int frameSize = width * height; - final boolean swap = rotation % 180 != 0; - final boolean xflip = flipHorizontal ? rotation % 270 == 0 : rotation % 270 != 0; - final boolean yflip = rotation >= 180; - - for (int j = 0; j < height; j++) { - for (int i = 0; i < width; i++) { - final int yIn = j * width + i; - final int uIn = frameSize + (j >> 1) * width + (i & ~1); - final int vIn = uIn + 1; - - final int wOut = swap ? height : width; - final int hOut = swap ? width : height; - final int iSwapped = swap ? j : i; - final int jSwapped = swap ? i : j; - final int iOut = xflip ? wOut - iSwapped - 1 : iSwapped; - final int jOut = yflip ? hOut - jSwapped - 1 : jSwapped; - - final int yOut = jOut * wOut + iOut; - final int uOut = frameSize + (jOut >> 1) * wOut + (iOut & ~1); - final int vOut = uOut + 1; - - output[yOut] = (byte)(0xff & yuv[yIn]); - output[uOut] = (byte)(0xff & yuv[uIn]); - output[vOut] = (byte)(0xff & yuv[vIn]); - } - } - return output; - } - public static Bitmap createFromDrawable(final Drawable drawable, final int width, final int height) { final AtomicBoolean created = new AtomicBoolean(false); final Bitmap[] result = new Bitmap[1]; @@ -427,28 +164,4 @@ public static int getMaxTextureSize() { return Math.min(maximumTextureSize, MAX_ALLOWED_TEXTURE_SIZE); } - public static class ScaleResult { - private final byte[] bitmap; - private final int width; - private final int height; - - public ScaleResult(byte[] bitmap, int width, int height) { - this.bitmap = bitmap; - this.width = width; - this.height = height; - } - - - public byte[] getBitmap() { - return bitmap; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt index 1ab547998e..08ecd450e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Intent import androidx.core.content.edit +import androidx.work.WorkManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -14,7 +15,6 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.database.Storage @@ -57,10 +57,11 @@ class ClearDataUtils @Inject constructor( application.filesDir.deleteRecursively() configFactory.clearAll() - RemoteFileDownloadWorker.cancelAll(application) - persistentLogger.deleteAllLogs() + // clean up existing work manager + WorkManager.getInstance(application).cancelAllWork() + // The token deletion is nice but not critical, so don't let it block the rest of the process runCatching { tokenFetcher.resetToken() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt deleted file mode 100644 index f228eb57a4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.ContentResolver -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import android.os.Looper -import androidx.annotation.CheckResult -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -/** - * Observe changes to a content Uri. This function will emit the Uri whenever the content or - * its descendants change, according to the parameter [notifyForDescendants]. - */ -@CheckResult -fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow { - return callbackFlow { - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - trySend(uri) - } - } - - registerContentObserver(uri, notifyForDescendants, observer) - awaitClose { - unregisterContentObserver(observer) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ImageUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ImageUtils.kt new file mode 100644 index 0000000000..7c9b105ead --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ImageUtils.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.util + +import coil3.decode.DecodeUtils +import coil3.gif.isGif +import coil3.gif.isWebP +import okio.BufferedSource +import okio.ByteString + +object ImageUtils { + private val JPEG_MAGICS = listOf( + ByteString.of(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()), + ByteString.of(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xDB.toByte()), + ByteString.of(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xEE.toByte()), + ByteString.of(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE1.toByte()), + ) + + private val PNG_MAGIC = ByteString.of( + 0x89.toByte(), + 0x50.toByte(), + 0x4E.toByte(), + 0x47.toByte(), + 0x0D.toByte(), + 0x0A.toByte(), + 0x1A.toByte(), + 0x0A.toByte() + ) + + + /** + * Returns true if the provided buffer contains a JPEG image. The buffer is not consumed. + */ + fun isJpeg(buffer: BufferedSource): Boolean { + return JPEG_MAGICS.any { buffer.rangeEquals(0, it) } + } + + /** + * Returns true if the provided buffer contains a PNG image. The buffer is not consumed. + */ + fun isPng(buffer: BufferedSource): Boolean { + return buffer.rangeEquals(0, PNG_MAGIC) + } + + /** + * Returns true if the provided buffer contains a GIF image. The buffer is not consumed. + */ + fun isGif(buffer: BufferedSource): Boolean = DecodeUtils.isGif(buffer) + + /** + * Returns true if the provided buffer contains a WebP image. The buffer is not consumed. + */ + fun isWebP(buffer: BufferedSource): Boolean = DecodeUtils.isWebP(buffer) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java deleted file mode 100644 index 321a3abfc0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import androidx.annotation.NonNull; - -import java.util.ArrayList; -import java.util.List; - -public final class ListUtil { - private ListUtil() {} - - public static List> chunk(@NonNull List list, int chunkSize) { - List> chunks = new ArrayList<>(list.size() / chunkSize); - - for (int i = 0; i < list.size(); i += chunkSize) { - List chunk = list.subList(i, Math.min(list.size(), i + chunkSize)); - chunks.add(chunk); - } - - return chunks; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java deleted file mode 100644 index f193827efd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.util; - - -import android.os.MemoryFile; -import android.os.ParcelFileDescriptor; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -public class MemoryFileUtil { - - public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) throws IOException { - try { - Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); - FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file); - - Field field = fileDescriptor.getClass().getDeclaredField("descriptor"); - field.setAccessible(true); - - int fd = field.getInt(fileDescriptor); - - return ParcelFileDescriptor.fromFd(fd); - } catch (IllegalAccessException e) { - throw new IOException(e); - } catch (InvocationTargetException e) { - throw new IOException(e); - } catch (NoSuchMethodException e) { - throw new IOException(e); - } catch (NoSuchFieldException e) { - throw new IOException(e); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt deleted file mode 100644 index 2cbe20aa00..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.graphics.PointF -import android.view.View -import org.thoughtcrime.securesms.util.hitRect - -fun PointF.distanceTo(other: PointF): Float { - return Math.sqrt(Math.pow(this.x.toDouble() - other.x.toDouble(), 2.toDouble()) + Math.pow(this.y.toDouble() - other.y.toDouble(), 2.toDouble())).toFloat() -} - -fun PointF.isLeftOf(view: View, margin: Float = 0.0f): Boolean { - return isContainedVerticallyIn(view, margin) && x < view.hitRect.left -} - -fun PointF.isAbove(view: View, margin: Float = 0.0f): Boolean { - return isContainedHorizontallyIn(view, margin) && y < view.hitRect.top -} - -fun PointF.isRightOf(view: View, margin: Float = 0.0f): Boolean { - return isContainedVerticallyIn(view, margin) && x > view.hitRect.right -} - -fun PointF.isBelow(view: View, margin: Float = 0.0f): Boolean { - return isContainedHorizontallyIn(view, margin) && y > view.hitRect.bottom -} - -fun PointF.isContainedHorizontallyIn(view: View, margin: Float = 0.0f): Boolean { - return x >= view.hitRect.left - margin || x <= view.hitRect.right + margin -} - -fun PointF.isContainedVerticallyIn(view: View, margin: Float = 0.0f): Boolean { - return y >= view.hitRect.top - margin || x <= view.hitRect.bottom + margin -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java b/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java deleted file mode 100644 index deba6b6516..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.util; - -import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.util.HashMap; -import java.util.Map; - -public class Rfc5724Uri { - - private final String uri; - private final String schema; - private final String path; - private final Map queryParams; - - public Rfc5724Uri(String uri) throws URISyntaxException { - this.uri = uri; - this.schema = parseSchema(); - this.path = parsePath(); - this.queryParams = parseQueryParams(); - } - - private String parseSchema() throws URISyntaxException { - String[] parts = uri.split(":"); - - if (parts.length < 1 || parts[0].isEmpty()) throw new URISyntaxException(uri, "invalid schema"); - else return parts[0]; - } - - private String parsePath() throws URISyntaxException { - String[] parts = uri.split("\\?")[0].split(":", 2); - - if (parts.length < 2 || parts[1].isEmpty()) throw new URISyntaxException(uri, "invalid path"); - else return parts[1]; - } - - private Map parseQueryParams() throws URISyntaxException { - Map queryParams = new HashMap<>(); - if (uri.split("\\?").length < 2) { - return queryParams; - } - - for (String keyValue : uri.split("\\?")[1].split("&")) { - String[] parts = keyValue.split("="); - - if (parts.length == 1) queryParams.put(parts[0], ""); - else queryParams.put(parts[0], URLDecoder.decode(parts[1])); - } - - return queryParams; - } - - public String getSchema() { - return schema; - } - - public String getPath() { - return path; - } - - public Map getQueryParams() { - return queryParams; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt deleted file mode 100644 index f7a2a7482f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.thoughtcrime.securesms.util - -import androidx.annotation.VisibleForTesting -import com.annimon.stream.Collectors -import com.annimon.stream.Stream - -object SqlUtil { - /** The maximum number of arguments (i.e. question marks) allowed in a SQL statement. */ - private const val MAX_QUERY_ARGS = 999 - - @JvmStatic - fun buildArgs(vararg objects: Any?): Array { - return objects.map { - when (it) { - null -> throw NullPointerException("Cannot have null arg!") - else -> it.toString() - } - }.toTypedArray() - } - - @JvmStatic - fun buildArgs(argument: Long): Array { - return arrayOf(argument.toString()) - } - - /** - * A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?) - * Handles breaking it - */ - @JvmStatic - fun buildCollectionQuery(column: String, values: Collection): List { - return buildCollectionQuery(column, values, MAX_QUERY_ARGS) - } - - @VisibleForTesting - @JvmStatic - fun buildCollectionQuery(column: String, values: Collection, maxSize: Int): List { - require(!values.isEmpty()) { "Must have values!" } - - return values - .chunked(maxSize) - .map { batch -> buildSingleCollectionQuery(column, batch) } - } - - /** - * A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?) - * - * Important: Should only be used if you know the number of values is < 1000. Otherwise you risk creating a SQL statement this is too large. - * Prefer [buildCollectionQuery] when possible. - */ - @JvmStatic - fun buildSingleCollectionQuery(column: String, values: Collection): Query { - require(!values.isEmpty()) { "Must have values!" } - - val query = StringBuilder() - val args = arrayOfNulls(values.size) - - for ((i, value) in values.withIndex()) { - query.append("?") - args[i] = value - if (i != values.size - 1) { - query.append(", ") - } - } - return Query("$column IN ($query)", buildArgs(*args)) - } - - @JvmStatic - fun buildCustomCollectionQuery(query: String, argList: List>): List { - return buildCustomCollectionQuery(query, argList, MAX_QUERY_ARGS) - } - - @JvmStatic - @VisibleForTesting - fun buildCustomCollectionQuery(query: String, argList: List>, maxQueryArgs: Int): List { - val batchSize: Int = maxQueryArgs / argList[0].size - return Stream.of(ListUtil.chunk(argList, batchSize)) - .map { argBatch -> buildSingleCustomCollectionQuery(query, argBatch) } - .collect(Collectors.toList()) - } - - private fun buildSingleCustomCollectionQuery(query: String, argList: List>): Query { - val outputQuery = StringBuilder() - val outputArgs: MutableList = mutableListOf() - - var i = 0 - val len = argList.size - - while (i < len) { - outputQuery.append("(").append(query).append(")") - if (i < len - 1) { - outputQuery.append(" OR ") - } - - val args = argList[i] - for (arg in args) { - outputArgs += arg - } - - i++ - } - - return Query(outputQuery.toString(), outputArgs.toTypedArray()) - } - - class Query(val where: String, val whereArgs: Array) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java deleted file mode 100644 index 0afe0192c0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java +++ /dev/null @@ -1,204 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.graphics.Canvas; -import android.graphics.Rect; -import android.os.Build.VERSION; -import androidx.annotation.NonNull; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; -import android.view.View; -import android.view.ViewGroup; - -import java.util.HashMap; -import java.util.Map; - -/** - * A sticky header decoration for android's RecyclerView. - * Currently only supports LinearLayoutManager in VERTICAL orientation. - */ -public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { - - private static final String TAG = StickyHeaderDecoration.class.getSimpleName(); - - private static final long NO_HEADER_ID = -1L; - - private final Map headerCache; - private final StickyHeaderAdapter adapter; - private final boolean renderInline; - private boolean sticky; - - /** - * @param adapter the sticky header adapter to use - */ - public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky) { - this.adapter = adapter; - this.headerCache = new HashMap<>(); - this.renderInline = renderInline; - this.sticky = sticky; - } - - /** - * {@inheritDoc} - */ - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, - @NonNull RecyclerView.State state) - { - int position = parent.getChildAdapterPosition(view); - int headerHeight = 0; - - if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) { - View header = getHeader(parent, adapter, position).itemView; - headerHeight = getHeaderHeightForLayout(header); - } - - outRect.set(0, headerHeight, 0, 0); - } - - protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) { - boolean isReverse = isReverseLayout(parent); - int itemCount = ((RecyclerView.Adapter)adapter).getItemCount(); - - if ((isReverse && adapterPos == itemCount - 1 && adapter.getHeaderId(adapterPos) != -1) || - (!isReverse && adapterPos == 0)) - { - return true; - } - - int previous = adapterPos + (isReverse ? 1 : -1); - long headerId = adapter.getHeaderId(adapterPos); - long previousHeaderId = adapter.getHeaderId(previous); - - return headerId != NO_HEADER_ID && previousHeaderId != NO_HEADER_ID && headerId != previousHeaderId; - } - - protected ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) { - final long key = adapter.getHeaderId(position); - - ViewHolder headerHolder = headerCache.get(key); - if (headerHolder == null) { - headerHolder = adapter.onCreateHeaderViewHolder(parent); - - //noinspection unchecked - adapter.onBindHeaderViewHolder(headerHolder, position); - - headerCache.put(key, headerHolder); - } - - final View header = headerHolder.itemView; - - int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); - int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); - - int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, - parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); - int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, - parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); - - header.measure(childWidth, childHeight); - header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); - - return headerHolder; - } - - /** - * {@inheritDoc} - */ - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - final int count = parent.getChildCount(); - - for (int layoutPos = 0; layoutPos < count; layoutPos++) { - final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos)); - - final int adapterPos = parent.getChildAdapterPosition(child); - - if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == 0 && sticky) || hasHeader(parent, adapter, adapterPos))) { - View header = getHeader(parent, adapter, adapterPos).itemView; - c.save(); - final int left = child.getLeft(); - final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); - c.translate(left, top); - header.draw(c); - c.restore(); - } - } - } - - protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, - int layoutPos) - { - int headerHeight = getHeaderHeightForLayout(header); - int top = (int)child.getY() - headerHeight; - if (sticky && layoutPos == 0) { - final int count = parent.getChildCount(); - final long currentId = adapter.getHeaderId(adapterPos); - // find next view with header and compute the offscreen push if needed - for (int i = 1; i < count; i++) { - int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i))); - if (adapterPosHere != RecyclerView.NO_POSITION) { - long nextId = adapter.getHeaderId(adapterPosHere); - if (nextId != currentId) { - final View next = parent.getChildAt(translatedChildPosition(parent, i)); - final int offset = (int)next.getY() - (headerHeight + getHeader(parent, adapter, adapterPosHere).itemView.getHeight()); - if (offset < 0) { - return offset; - } else { - break; - } - } - } - } - - if (sticky) top = Math.max(0, top); - } - - return top; - } - - private int translatedChildPosition(RecyclerView parent, int position) { - return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position; - } - - protected int getHeaderHeightForLayout(View header) { - return renderInline ? 0 : header.getHeight(); - } - - private boolean isReverseLayout(final RecyclerView parent) { - return (parent.getLayoutManager() instanceof LinearLayoutManager) && - ((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout(); - } - - /** - * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. - * - * @param the header view holder - */ - public interface StickyHeaderAdapter { - - /** - * Returns the header id for the item at the given position. - * - * @param position the item position - * @return the header id - */ - long getHeaderId(int position); - - /** - * Creates a new header ViewHolder. - * - * @param parent the header's view parent - * @return a view holder for the created view - */ - T onCreateHeaderViewHolder(ViewGroup parent); - - /** - * Updates the header view to reflect the header data for the given position - * @param viewHolder the header view holder - * @param position the header's item position - */ - void onBindHeaderViewHolder(T viewHolder, int position); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java index 1f32748155..cfd0b64960 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java @@ -30,52 +30,6 @@ public static void close(@Nullable Closeable closeable) { } } - public static long getStreamLength(InputStream in) throws IOException { - byte[] buffer = new byte[4096]; - int totalSize = 0; - - int read; - - while ((read = in.read(buffer)) != -1) { - totalSize += read; - } - - return totalSize; - } - - public static void readFully(InputStream in, byte[] buffer) throws IOException { - readFully(in, buffer, buffer.length); - } - - public static void readFully(InputStream in, byte[] buffer, int len) throws IOException { - int offset = 0; - - for (;;) { - int read = in.read(buffer, offset, len - offset); - if (read == -1) throw new EOFException("Stream ended early"); - - if (read + offset < len) offset += read; - else return; - } - } - - public static byte[] readFully(InputStream in) throws IOException { - ByteArrayOutputStream bout = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; - int read; - - while ((read = in.read(buffer)) != -1) { - bout.write(buffer, 0, read); - } - - in.close(); - - return bout.toByteArray(); - } - - public static String readFullyAsString(InputStream in) throws IOException { - return new String(readFully(in)); - } public static long copy(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[64 * 1024]; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 97f1257b28..5f1291ceb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -20,8 +20,6 @@ import android.content.Context; import android.text.TextUtils; -import org.thoughtcrime.securesms.components.ComposeText; - import network.loki.messenger.BuildConfig; public class Util { @@ -31,10 +29,6 @@ public static boolean isLowMemory(Context context) { return (activityManager.isLowRamDevice()) || activityManager.getLargeMemoryClass() <= 64; } - public static boolean isEmpty(ComposeText value) { - return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); - } - public static int getCanonicalVersionCode() { return BuildConfig.CANONICAL_VERSION_CODE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt index 388803a15b..0c4035dbd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt @@ -18,14 +18,15 @@ private val REFRESH_TIME_MS = 4.hours.inWholeMilliseconds @Singleton class VersionDataFetcher @Inject constructor( - private val prefs: TextSecurePreferences + private val prefs: TextSecurePreferences, + private val fileServerApi: FileServerApi, ) : OnAppStartupComponent { private val handler = Handler(Looper.getMainLooper()) private val fetchVersionData = Runnable { scope.launch { try { // Perform the version check - val clientVersion = FileServerApi.getClientVersion() + val clientVersion = fileServerApi.getClientVersion() Log.i(TAG, "Fetched version data: $clientVersion") prefs.setLastVersionCheck() startTimedVersionCheck() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 4c275ca886..7b965f2e3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -46,24 +46,6 @@ val View.hitRect: Rect @ColorInt fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent) -// Method to grab the appropriate attribute for a message colour. -// Note: This is an attribute, NOT a resource Id - see `getColorResourceIdFromAttr` for that. -@AttrRes -fun getMessageTextColourAttr(messageIsOutgoing: Boolean): Int { - return if (messageIsOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color -} - -// Method to get an actual R.id. resource Id from an attribute such as R.attr.message_sent_text_color etc. -@ColorRes -fun getColorResourceIdFromAttr(context: Context, attr: Int): Int { - val outTypedValue = TypedValue() - val successfullyFoundAttribute = context.theme.resolveAttribute(attr, outTypedValue, true) - if (successfullyFoundAttribute) { return outTypedValue.resourceId } - - Log.w("ViewUtils", "Could not find colour attribute $attr in theme - using grey as a safe fallback") - return R.color.gray50 -} - fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) { val startSize = resources.getDimension(startSizeID) val endSize = resources.getDimension(endSizeID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt index b6304f463b..aebd64a222 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt @@ -1,24 +1,9 @@ package org.thoughtcrime.securesms.util.adapter -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnPreDraw -import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView -import kotlin.math.max - -// Makes sure that the recyclerView is scrolled to the bottom -fun RecyclerView.applyImeBottomPadding() { - clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets -> - val system = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom - val ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom - v.updatePadding(bottom = max(system, ime)) - insets - } -} // Handle scroll logic fun RecyclerView.handleScrollToBottom(fastScroll: Boolean = false) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt deleted file mode 100644 index 88b41d11cd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.thoughtcrime.securesms.util.adapter - -data class SelectableItem(val item: T, val isSelected: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt index c6dd6525e9..1171fc190b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt @@ -26,7 +26,7 @@ open class AudioManagerCommand: Parcelable { data class Stop(val playDisconnect: Boolean): AudioManagerCommand() @Parcelize - data class StartIncomingRinger(val vibrate: Boolean): AudioManagerCommand() + data object StartIncomingRinger: AudioManagerCommand() @Parcelize data class SetUserDevice(val device: SignalAudioManager.AudioDevice): AudioManagerCommand() diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 62be057c74..5a864b0f80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -5,11 +5,13 @@ import android.content.pm.PackageManager import android.telephony.TelephonyManager import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.boolean @@ -29,6 +31,7 @@ import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Util import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat @@ -68,6 +71,7 @@ import org.thoughtcrime.securesms.webrtc.data.State as CallState @Singleton class CallManager @Inject constructor( @param:ApplicationContext private val context: Context, + @param:ManagerScope private val scope: CoroutineScope, audioManager: AudioManagerCompat, private val storage: StorageProtocol, ): PeerConnection.Observer, @@ -333,7 +337,17 @@ class CallManager @Inject constructor( currentCallId ) .applyExpiryMode(expectedRecipient) - .also { MessageSender.sendNonDurably(it, currentRecipient, isSyncMessage = currentRecipient.isLocalNumber) } + .also { + scope.launch { + runCatching { + MessageSender.sendNonDurably( + it, + currentRecipient, + isSyncMessage = currentRecipient.isLocalNumber + ) + } + } + } } } @@ -457,13 +471,13 @@ class CallManager @Inject constructor( } } - fun onNewOffer(offer: String, callId: UUID, recipient: Address): Promise { - if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) - if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) - val connection = peerConnection ?: return Promise.ofFail(NullPointerException("No peer connection wrapper")) + suspend fun onNewOffer(offer: String, callId: UUID, recipient: Address) { + if (callId != this.callId) throw NullPointerException("No callId") + if (recipient != this.recipient) throw NullPointerException("No recipient") + val connection = peerConnection ?: throw NullPointerException("No peer connection wrapper") val reconnected = stateProcessor.processEvent(Event.ReceiveOffer) && stateProcessor.processEvent(Event.SendAnswer) - return if (reconnected) { + if (reconnected) { Log.i("Loki", "Handling new offer, restarting ice session") connection.setNewRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) // re-established an ice @@ -475,9 +489,16 @@ class CallManager @Inject constructor( pendingIncomingIceUpdates.clear() val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(recipient) Log.i("Loki", "Posting new answer") - MessageSender.sendNonDurably(answerMessage, recipient, isSyncMessage = recipient.isLocalNumber) + + runCatching { + MessageSender.sendNonDurably( + answerMessage, + recipient, + isSyncMessage = recipient.isLocalNumber + ) + } } else { - Promise.ofFail(Exception("Couldn't reconnect from current state")) + throw Exception("Couldn't reconnect from current state") } } @@ -493,15 +514,15 @@ class CallManager @Inject constructor( } } - fun onIncomingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + suspend fun onIncomingCall(context: Context, isAlwaysTurn: Boolean = false) { lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) - val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) - val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) - val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null")) - val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) - val local = floatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) - val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) + val callId = callId ?: throw NullPointerException("callId is null") + val recipient = recipient ?: throw NullPointerException("recipient is null") + val offer = pendingOffer ?: throw NullPointerException("pendingOffer is null") + val factory = peerConnectionFactory ?: throw NullPointerException("peerConnectionFactory is null") + val local = floatingRenderer ?: throw NullPointerException("localRenderer is null") + val base = eglBase ?: throw NullPointerException("eglBase is null") val connection = PeerConnectionWrapper( context, @@ -521,12 +542,24 @@ class CallManager @Inject constructor( val answer = connection.createAnswer(MediaConstraints()) connection.setLocalDescription(answer) val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(recipient) - val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) - MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) - val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( - answer.description, - callId - ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) + val userAddress = storage.getUserPublicKey() ?: throw NullPointerException("No user public key") + + runCatching { + MessageSender.sendNonDurably( + answerMessage, + Address.fromSerialized(userAddress), + isSyncMessage = true + ) + } + + runCatching { + MessageSender.sendNonDurably( + CallMessage.answer( + answer.description, + callId + ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber + ) + } insertCallMessage(recipient.toString(), CallMessageType.CALL_INCOMING, false) @@ -534,28 +567,27 @@ class CallManager @Inject constructor( val candidate = pendingIncomingIceUpdates.pop() ?: break connection.addIceCandidate(candidate) } - return sendAnswerMessage.success { - pendingOffer = null - pendingOfferTime = -1 - } + + pendingOffer = null + pendingOfferTime = -1 } - fun onOutgoingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + suspend fun onOutgoingCall(context: Context, isAlwaysTurn: Boolean = false) { lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) - val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) + val callId = callId ?: throw NullPointerException("callId is null") val recipient = recipient - ?: return Promise.ofFail(NullPointerException("recipient is null")) + ?: throw NullPointerException("recipient is null") val factory = peerConnectionFactory - ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) + ?: throw NullPointerException("peerConnectionFactory is null") val local = floatingRenderer - ?: return Promise.ofFail(NullPointerException("localRenderer is null")) - val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) + ?: throw NullPointerException("localRenderer is null") + val base = eglBase ?: throw NullPointerException("eglBase is null") val sentOffer = stateProcessor.processEvent(Event.SendOffer) if (!sentOffer) { - return Promise.ofFail(Exception("Couldn't transition to sent offer state")) + throw Exception("Couldn't transition to sent offer state") } else { val connection = PeerConnectionWrapper( context, @@ -576,20 +608,26 @@ class CallManager @Inject constructor( connection.setLocalDescription(offer) Log.d("Loki", "Sending pre-offer") - return MessageSender.sendNonDurably(CallMessage.preOffer( - callId - ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber).bind { + try { + MessageSender.sendNonDurably( + CallMessage.preOffer( + callId + ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber + ) + Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") postViewModelState(CallViewModel.State.CALL_OFFER_OUTGOING) + MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber).success { - Log.d("Loki", "Sent offer") - }.fail { - Log.e("Loki", "Failed to send offer", it) - } + ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) + + Log.d("Loki", "Sent offer") + } catch (e: Exception) { + Log.e("Loki", "Failed to send offer", e) + throw e } } } @@ -599,10 +637,26 @@ class CallManager @Inject constructor( val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(recipient), Address.fromSerialized(userAddress), isSyncMessage = true) - MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) - insertCallMessage(recipient.toString(), CallMessageType.CALL_INCOMING) + scope.launch { + runCatching { + MessageSender.sendNonDurably( + CallMessage.endCall(callId).applyExpiryMode(recipient), + Address.fromSerialized(userAddress), + isSyncMessage = true + ) + } + } + scope.launch { + runCatching { + MessageSender.sendNonDurably( + CallMessage.endCall(callId).applyExpiryMode(recipient), + recipient, + isSyncMessage = recipient.isLocalNumber + ) + } + } + insertCallMessage(recipient.toString(), CallMessageType.CALL_INCOMING) } } @@ -624,7 +678,15 @@ class CallManager @Inject constructor( channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) + scope.launch { + runCatching { + MessageSender.sendNonDurably( + CallMessage.endCall(callId).applyExpiryMode(recipient), + recipient, + isSyncMessage = recipient.isLocalNumber + ) + } + } } } @@ -823,7 +885,7 @@ class CallManager @Inject constructor( } fun startIncomingRinger() { - signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger(true)) + signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger) } fun startCommunication() { @@ -855,7 +917,15 @@ class CallManager @Inject constructor( mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(offer) - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) + scope.launch { + runCatching { + MessageSender.sendNonDurably( + CallMessage.offer(offer.description, callId).applyExpiryMode(recipient), + recipient, + isSyncMessage = recipient.isLocalNumber + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt index 155e02e6a2..70c0a68b21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt @@ -50,6 +50,7 @@ import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException import org.thoughtcrime.securesms.webrtc.data.State as CallState //todo PHONE We want to eventually remove this bridging class and move the logic here to a better place, probably in the callManager @@ -66,7 +67,7 @@ class WebRtcCallBridge @Inject constructor( private val networkConnectivity: NetworkConnectivity, private val recipientRepository: RecipientRepository, private val storage: StorageProtocol, - @ManagerScope scope: CoroutineScope, + @ManagerScope private val scope: CoroutineScope, ): CallManager.WebRtcListener, OnAppStartupComponent { companion object { @@ -159,10 +160,17 @@ class WebRtcCallBridge @Inject constructor( private fun handleNewOffer(address: Address, sdp: String, callId: UUID) { Log.d(TAG, "Handle new offer") - callManager.onNewOffer(sdp, callId, address).fail { - Log.e("Loki", "Error handling new offer", it) - callManager.postConnectionError() - terminate() + scope.launch { + try { + callManager.onNewOffer(sdp, callId, address) + } catch (e: CancellationException) { + Log.d(TAG, "onNewOffer coroutine cancelled", e) + throw e + } catch (e: Exception) { + Log.e("Loki", "Error handling new offer", e) + callManager.postConnectionError() + terminate() + } } } @@ -268,9 +276,10 @@ class WebRtcCallBridge @Inject constructor( val expectedState = callManager.currentConnectionState val expectedCallId = callManager.callId - try { - val offerFuture = callManager.onOutgoingCall(context) - offerFuture.fail { e -> + scope.launch { + try { + callManager.onOutgoingCall(context) + } catch (e: Exception) { if (isConsistentState( expectedState, expectedCallId, @@ -278,16 +287,13 @@ class WebRtcCallBridge @Inject constructor( callManager.callId ) ) { - Log.e(TAG, e) callManager.postViewModelState(CallViewModel.State.NETWORK_FAILURE) - callManager.postConnectionError() - terminate() } + + Log.e(TAG, e) + callManager.postConnectionError() + terminate() } - } catch (e: Exception) { - Log.e(TAG, e) - callManager.postConnectionError() - terminate() } } } @@ -348,9 +354,12 @@ class WebRtcCallBridge @Inject constructor( val expectedState = callManager.currentConnectionState val expectedCallId = callManager.callId - try { - val answerFuture = callManager.onIncomingCall(context) - answerFuture.fail { e -> + scope.launch { + try { + callManager.onIncomingCall(context) + } catch (e: Exception) { + Log.e(TAG, "incoming call error: $e") + if (isConsistentState( expectedState, expectedCallId, @@ -358,19 +367,15 @@ class WebRtcCallBridge @Inject constructor( callManager.callId ) ) { - Log.e(TAG, "incoming call error: $e") insertMissedCall( recipient, true ) //todo PHONE do we want a missed call in this case? Or just [xxx] called you ? - callManager.postConnectionError() - terminate() } + + callManager.postConnectionError() + terminate() } - } catch (e: Exception) { - Log.e(TAG, e) - callManager.postConnectionError() - terminate() } } } @@ -418,22 +423,28 @@ class WebRtcCallBridge @Inject constructor( fun handleAnswerIncoming(address: Address, sdp: String, callId: UUID) { serviceExecutor.execute { - try { - if (address.isLocalNumber && callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { - handleLocalHangup(address) - return@execute + val state = callManager.currentConnectionState + val isInitiator = callManager.isInitiator() + + // If we receive a self-synced ANSWER: + if (address.isLocalNumber) { + // Only act if this device was in an INCOMING ring state (answered elsewhere). + if (!isInitiator && state in arrayOf(CallState.RemotePreOffer, CallState.RemoteRing)) { + // Stop ringing / update UI, but DO NOT hang up the remote. + callManager.silenceIncomingRinger() + callManager.handleIgnoreCall() + terminate() + } else { + // We’re the caller or already past ring → ignore self-answer + Log.w(TAG, "Ignoring self-synced ANSWER in state=$state (isInitiator=$isInitiator)") } - - callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_OUTGOING) - - callManager.handleResponseMessage( - address, - callId, - SessionDescription(SessionDescription.Type.ANSWER, sdp) - ) - } catch (e: PeerConnectionException) { - terminate() + return@execute } + + callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_OUTGOING) + callManager.handleResponseMessage( + address, callId, SessionDescription(SessionDescription.Type.ANSWER, sdp) + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt index 1b3995a2aa..36a4658ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt @@ -1,10 +1,16 @@ package org.thoughtcrime.securesms.webrtc.audio +import android.app.NotificationManager import android.content.Context +import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect import android.os.Vibrator +import android.os.VibratorManager import org.session.libsession.utilities.ServiceUtil import org.session.libsignal.utilities.Log @@ -14,56 +20,89 @@ class IncomingRinger(private val context: Context) { val PATTERN = longArrayOf(0L, 1000L, 1000L) } - private val vibrator: Vibrator? = ServiceUtil.getVibrator(context) var mediaPlayer: MediaPlayer? = null val isRinging: Boolean get() = mediaPlayer?.isPlaying ?: false - fun start(vibrate: Boolean) { + fun start() { val audioManager = ServiceUtil.getAudioManager(context) mediaPlayer?.release() mediaPlayer = createMediaPlayer() - val ringerMode = audioManager.ringerMode - if (shouldVibrate(mediaPlayer, ringerMode, vibrate)) { - Log.i(TAG,"Starting vibration") - vibrator?.vibrate(PATTERN, 1) - } else { - Log.i(TAG,"Skipping vibration") - } + // Vibrate if policy/system allows + if (shouldVibrate(audioManager)) vibrate() + // Play ringtone only in NORMAL mediaPlayer?.let { player -> - if (ringerMode == AudioManager.RINGER_MODE_NORMAL) { + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL) { try { if (!player.isPlaying) { player.prepare() player.start() - Log.i(TAG,"Playing ringtone") + Log.i(TAG, "Playing ringtone") } } catch (e: Exception) { - Log.e(TAG,"Failed to start mediaPlayer", e) + Log.e(TAG, "Failed to start mediaPlayer", e) } } } ?: run { - Log.w(TAG,"Not ringing, mediaPlayer: ${mediaPlayer?.let{"available"}}, mode: $ringerMode") + Log.w(TAG,"Not ringing, mediaPlayer: ${mediaPlayer?.let{"available"}}") } - } fun stop() { mediaPlayer?.release() mediaPlayer = null - vibrator?.cancel() + if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(VibratorManager::class.java) + ?.defaultVibrator?.cancel() + } else { + context.getSystemService(Vibrator::class.java)?.cancel() + } } - private fun shouldVibrate(player: MediaPlayer?, ringerMode: Int, vibrate: Boolean): Boolean { - player ?: return true + private fun shouldVibrate(audioManager: AudioManager): Boolean { + val v = ServiceUtil.getVibrator(context) ?: return false + if (!v.hasVibrator()) return false + + // Respect 'Do Not Disturb' + val nm = context.getSystemService(NotificationManager::class.java) + when (nm?.currentInterruptionFilter) { + NotificationManager.INTERRUPTION_FILTER_NONE, + NotificationManager.INTERRUPTION_FILTER_ALARMS -> return false + } + + return when (audioManager.ringerMode) { + AudioManager.RINGER_MODE_SILENT -> false + AudioManager.RINGER_MODE_VIBRATE -> true + AudioManager.RINGER_MODE_NORMAL -> true + else -> false + } + } - if (vibrator == null || !vibrator.hasVibrator()) return false + private fun vibrate() { + if (Build.VERSION.SDK_INT >= 31) { + val vm = context.getSystemService(VibratorManager::class.java) ?: return + val v = vm.defaultVibrator + if (!v.hasVibrator()) return + + val effect = VibrationEffect.createWaveform(PATTERN, 1) + if (Build.VERSION.SDK_INT >= 33) { + val attrs = VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_RINGTONE) + .build() + v.vibrate(effect, attrs) + } else { + v.vibrate(effect) + } + } else { + val v = context.getSystemService(Vibrator::class.java) ?: return + if (!v.hasVibrator()) return - return if (vibrate) ringerMode != AudioManager.RINGER_MODE_SILENT - else ringerMode == AudioManager.RINGER_MODE_VIBRATE + val effect = VibrationEffect.createWaveform(PATTERN, 1) + v.vibrate(effect) + } } private fun createMediaPlayer(): MediaPlayer? { @@ -76,9 +115,17 @@ class IncomingRinger(private val context: Context) { } ?: return null try { - val mediaPlayer = MediaPlayer() - mediaPlayer.setDataSource(context, defaultRingtone) - return mediaPlayer + return MediaPlayer().apply { + // Make volume follow the "Ring & notification" slider + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + isLooping = true + setDataSource(context, defaultRingtone) + } } catch (e: SecurityException) { Log.w(TAG, "Failed to create player with ringtone the normal way", e) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index de3636f502..11f26f9738 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -72,7 +72,7 @@ class SignalAudioManager(private val context: Context, is AudioManagerCommand.Stop -> stop(command.playDisconnect) is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.device, command.clearUserEarpieceSelection) is AudioManagerCommand.SetUserDevice -> selectAudioDevice(command.device) - is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger(command.vibrate) + is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger() is AudioManagerCommand.SilenceIncomingRinger -> silenceIncomingRinger() is AudioManagerCommand.StartOutgoingRinger -> startOutgoingRinger(command.type) } @@ -331,11 +331,11 @@ class SignalAudioManager(private val context: Context, } } - private fun startIncomingRinger(vibrate: Boolean) { - Log.i(TAG, "startIncomingRinger(): vibrate: $vibrate") + private fun startIncomingRinger() { + Log.i(TAG, "startIncomingRinger()") androidAudioManager.mode = AudioManager.MODE_RINGTONE - incomingRinger.start(vibrate) + incomingRinger.start() } private fun silenceIncomingRinger() { diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml index bb74bea869..c48887a8ec 100644 --- a/app/src/main/res/layout/view_global_search_result.xml +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -39,15 +39,22 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" android:orientation="vertical"> - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintHorizontal_bias="0" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toStartOf="@+id/unreadCountIndicator" + /> @@ -77,13 +91,15 @@ android:alpha="0.4" android:layout_weight="1" android:paddingStart="@dimen/small_spacing" - android:layout_gravity="end|center_vertical" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" android:id="@+id/search_result_timestamp" tools:text="@tools:sample/date/hhmmss" android:textAlignment="viewEnd" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content"/> - + - - - - diff --git a/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt new file mode 100644 index 0000000000..ba148e2e26 --- /dev/null +++ b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt @@ -0,0 +1,113 @@ +package org.session.libsession.messaging.file_server + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.* +import org.junit.Test +import org.mockito.kotlin.mock + +class FileServerApiTest { + + + private data class Case( + val name: String, + val url: HttpUrl, + val successfulParseResult: FileServerApi.URLParseResult?, + ) + + @Test + fun `can build and parse attachment url`() { + val api = FileServerApi(storage = mock()) + + val testCases = listOf( + Case( + name = "With deterministic flag", + url = "http://fileserver/file/id1#d&p=1234".toHttpUrl(), + successfulParseResult = FileServerApi.URLParseResult( + fileId = "id1", + usesDeterministicEncryption = true, + fileServer = FileServer( + url = "http://fileserver".toHttpUrl(), + ed25519PublicKeyHex = "1234" + ) + ) + ), + Case( + name = "With deterministic flag variant1", + url = "http://fileserver/file/id1#d=&p=1234".toHttpUrl(), + successfulParseResult = FileServerApi.URLParseResult( + fileId = "id1", + usesDeterministicEncryption = true, + fileServer = FileServer( + url = "http://fileserver".toHttpUrl(), + ed25519PublicKeyHex = "1234" + ) + ) + ), + Case( + name = "Without deterministic flag", + url = "http://fileserver/file/id1#p=1234".toHttpUrl(), + successfulParseResult = FileServerApi.URLParseResult( + fileId = "id1", + usesDeterministicEncryption = false, + fileServer = FileServer( + url = "http://fileserver".toHttpUrl(), + ed25519PublicKeyHex = "1234" + ) + ) + ), + Case( + name = "Official server without public key", + url = "http://${FileServerApi.DEFAULT_FILE_SERVER.url.host}/file/id1".toHttpUrl(), + successfulParseResult = FileServerApi.URLParseResult( + fileId = "id1", + usesDeterministicEncryption = false, + fileServer = FileServerApi.DEFAULT_FILE_SERVER + ), + ), + Case( + name = "Alt official server without public key", + url = "http://fileabc.getsession.org/file/id1".toHttpUrl(), + successfulParseResult = FileServerApi.URLParseResult( + fileId = "id1", + usesDeterministicEncryption = false, + fileServer = FileServer( + "http://fileabc.getsession.org", + FileServerApi.DEFAULT_FILE_SERVER.ed25519PublicKeyHex + ) + ), + ), + + // Error cases + Case( + name = "Missing file id", + url = "http://fileserver/file/#d&p=1234".toHttpUrl(), + successfulParseResult = null + ), + Case( + name = "Missing public key", + url = "http://fileserver/file/id1#d".toHttpUrl(), + successfulParseResult = null + ), + ) + + for (case in testCases) { + try { + val result = runCatching { api.parseAttachmentUrl(case.url) } + if (case.successfulParseResult != null) { + val actual = result.getOrThrow() + assertEquals("Parse result differs!",case.successfulParseResult, actual) + + val url = api.buildAttachmentUrl(actual.fileId, actual.fileServer, actual.usesDeterministicEncryption) + val reversed = api.parseAttachmentUrl(url) + assertEquals("Build URL differs!", actual, reversed) + + } else { + assertTrue("Parse result differs!", result.isFailure) + } + } catch (e: Exception) { + throw RuntimeException("Case failed: ${case.name}", e) + } + } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java deleted file mode 100644 index 9d75662ebc..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.util; - -import junit.framework.AssertionFailedError; - -import org.junit.Test; - -import java.net.URISyntaxException; - -import static org.junit.Assert.assertTrue; - -public class Rfc5724UriTest { - - @Test public void testInvalidPath() throws Exception { - final String[] invalidSchemaUris = { - "", - ":", - "sms:", - ":sms", - "sms:?goto=fail", - "sms:?goto=fail&fail=goto" - }; - - for (String uri : invalidSchemaUris) { - try { - new Rfc5724Uri(uri); - throw new AssertionFailedError("URISyntaxException should be thrown"); - } catch (URISyntaxException e) { - // success - } - } - } - - @Test public void testGetSchema() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "sms"}, - {"sMs:+15555555555", "sMs"}, - {"smsto:+15555555555?", "smsto"}, - {"mms:+15555555555?a=b", "mms"}, - {"mmsto:+15555555555?a=b&c=d", "mmsto"} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - assertTrue(testUri.getSchema().equals(uriTestPair[1])); - } - } - - @Test public void testGetPath() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "+15555555555"}, - {"sms:%2B555555555", "%2B555555555"}, - {"smsto:+15555555555?", "+15555555555"}, - {"mms:+15555555555?a=b", "+15555555555"}, - {"mmsto:+15555555555?a=b&c=d", "+15555555555"}, - {"sms:+15555555555,+14444444444", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?a=b", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?a=b&c=d", "+15555555555,+14444444444"} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - assertTrue(testUri.getPath().equals(uriTestPair[1])); - } - } - - @Test public void testGetQueryParams() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "a", null}, - {"mms:+15555555555?b=", "a", null}, - {"mmsto:+15555555555?a=", "a", ""}, - {"sms:+15555555555?a=b", "a", "b"}, - {"sms:+15555555555?a=b&c=d", "a", "b"}, - {"sms:+15555555555?a=b&c=d", "b", null}, - {"sms:+15555555555?a=b&c=d", "c", "d"}, - {"sms:+15555555555?a=b&c=d", "d", null} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - final String paramResult = testUri.getQueryParams().get(uriTestPair[1]); - - if (paramResult == null) assertTrue(uriTestPair[2] == null); - else assertTrue(paramResult.equals(uriTestPair[2])); - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed3b35a9ef..9fed3068fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ kotlinVersion = "2.2.20" kryoVersion = "5.6.2" kspVersion = "2.2.10-2.0.2" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.8-1-g27817b4" +libsessionUtilAndroidVersion = "1.0.9-2-g8c03d1e" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" navVersion = "2.9.4" @@ -143,6 +143,7 @@ glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0 glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glideVersion" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "daggerHiltVersion" } dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHiltVersion" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutinesVersion" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 84950d94d6..e4a1f90aa7 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -62,11 +62,6 @@ - - - - - diff --git a/scripts/build-and-release.py b/scripts/build-and-release.py index 827f810126..baa2386433 100755 --- a/scripts/build-and-release.py +++ b/scripts/build-and-release.py @@ -224,7 +224,7 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent description='Build and release script for Session Android' ) -parser.add_argument('--build-play-only', action='store_true', help='If set, will only build Play releases and skip F-Droid and Huawei releases.') +parser.add_argument('--build-only', action='store_true', help='If set, will only build APKs and skip all upload/fdroid actions') parser.add_argument('--build-type', help='Build with specified build type. Default: release', default = 'release') args = parser.parse_args() @@ -260,10 +260,6 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent build_type=args.build_type, ) -if args.build_play_only: - print('Skipping F-Droid and Huawei releases as --build-play-only is set.') - sys.exit(0) - print("Building fdroid releases...") fdroid_build_result = build_releases( project_root=project_root, @@ -273,8 +269,9 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent build_type=args.build_type, ) -print("Updating fdroid repo...") -update_fdroid(build=fdroid_build_result, creds=BuildCredentials(credentials['fdroid']), fdroid_workspace=os.path.join(fdroid_repo_path, 'fdroid')) +if not args.build_only: + print("Updating fdroid repo...") + update_fdroid(build=fdroid_build_result, creds=BuildCredentials(credentials['fdroid']), fdroid_workspace=os.path.join(fdroid_repo_path, 'fdroid')) print("Building huawei releases...") huawei_build_result = build_releases( @@ -287,23 +284,24 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent ) # If the a github release draft exists, upload the apks to the release -try: - release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) - if release_info['isDraft'] == True: - print(f'Uploading build artifact to the release {play_build_result.version_name} draft...') - files_to_upload = [*play_build_result.apk_paths, - play_build_result.bundle_path, - *huawei_build_result.apk_paths] - upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload] - subprocess.run(upload_commands, shell=False, cwd=project_root, check=True) - - print('Successfully uploaded these files to the draft release: ') - for file in files_to_upload: - print(file) - else: - print(f'Release {play_build_result.version_name} not a draft. Skipping upload of apks to the release.') -except subprocess.CalledProcessError: - print(f'{play_build_result.version_name} has not had a release draft created. Skipping upload of apks to the release.') +if not args.build_only: + try: + release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) + if release_info['isDraft'] == True: + print(f'Uploading build artifact to the release {play_build_result.version_name} draft...') + files_to_upload = [*play_build_result.apk_paths, + play_build_result.bundle_path, + *huawei_build_result.apk_paths] + upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload] + subprocess.run(upload_commands, shell=False, cwd=project_root, check=True) + + print('Successfully uploaded these files to the draft release: ') + for file in files_to_upload: + print(file) + else: + print(f'Release {play_build_result.version_name} not a draft. Skipping upload of apks to the release.') + except subprocess.CalledProcessError: + print(f'{play_build_result.version_name} has not had a release draft created. Skipping upload of apks to the release.') print('\n=====================')