From a741d2064c643cf1802beb41494c2d2a806a03ec Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:30:33 +1100 Subject: [PATCH 01/10] Fix crash when sending out read receipt (#1957) Co-authored-by: ThomasSession (cherry picked from commit d7a0ee8c5989a726e53cd45e19e6b5e1b37ef153) --- .../thoughtcrime/securesms/notifications/MarkReadProcessor.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index b90a4501a9..04bcd97df4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -132,7 +132,6 @@ class MarkReadProcessor @Inject constructor( private val Recipient.shouldSendReadReceipt: Boolean get() = when (data) { is RecipientData.Contact -> approved && !blocked - is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked else -> false } From 685e452eefa07c42712820bff391dc8d1e507a2b Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:34:29 +1100 Subject: [PATCH 02/10] Add API name when logging server error (#1964) (cherry picked from commit 2ee175e8ad0b7802c97627e66df17a5ef3490642) --- .../java/org/thoughtcrime/securesms/api/server/ServerApi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt index c3e18bd217..cea9da67c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt @@ -45,7 +45,7 @@ abstract class ServerApi( ctx = failureContext, ) - Log.d("ServerApi", "Network error for a Server endpoint ($baseUrl), with status:${response.statusCode} - error: $error") + Log.d("ServerApi", "Network error from ${this.javaClass.simpleName} for a Server endpoint ($baseUrl), with status:${response.statusCode} - error: $error") executorContext.set( key = ServerClientFailureContextKey, From 392885365cbac7a008837403e256fa38e6a95f8e Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:23:08 +1100 Subject: [PATCH 03/10] Optimizing community joining process (#1968) (cherry picked from commit 016f699c2187ce2581e8bf513054d5f98243faeb) --- .../libsession/database/StorageProtocol.kt | 2 - .../messaging/open_groups/OpenGroupApi.kt | 19 ++++- .../open_groups/api/GetRoomDetailsApi.kt | 38 +++++++++ .../securesms/database/Storage.kt | 4 - .../securesms/groups/OpenGroupManager.kt | 84 +++++++++++-------- 5 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomDetailsApi.kt 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 030b5071c9..33d128d6e0 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -59,8 +59,6 @@ interface StorageProtocol { fun getServerCapabilities(server: String): List? fun clearServerCapabilities(server: String) - // Open Groups - suspend fun addOpenGroup(urlAsString: String) fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) // Open Group Public Keys 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 2627546900..5e471611fb 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 @@ -102,7 +102,24 @@ object OpenGroupApi { @SerialName("default_upload") val defaultUpload: Boolean = false, val details: RoomInfoDetails = RoomInfoDetails() - ) + ) { + constructor(details: RoomInfoDetails): this( + token = details.token, + activeUsers = details.activeUsers, + admin = details.admin, + globalAdmin = details.globalAdmin, + moderator = details.moderator, + globalModerator = details.globalModerator, + read = details.read, + defaultRead = details.defaultRead, + defaultAccessible = details.defaultAccessible, + write = details.write, + defaultWrite = details.defaultWrite, + upload = details.upload, + defaultUpload = details.defaultUpload, + details = details + ) + } @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @Serializable diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomDetailsApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomDetailsApi.kt new file mode 100644 index 0000000000..85b18295a9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomDetailsApi.kt @@ -0,0 +1,38 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class GetRoomDetailsApi @AssistedInject constructor( + deps: CommunityApiDependencies, + @Assisted override val room: String, +) : CommunityApi(deps) { + override val requiresSigning: Boolean + get() = false + + override val httpMethod: String + get() = "GET" + + override val httpEndpoint: String + get() = "/room/${room}" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): OpenGroupApi.RoomInfoDetails { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream().use(json::decodeFromStream) + } + + @AssistedFactory + interface Factory { + fun create(room: String): GetRoomDetailsApi + } +} 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 a8148a7490..23da27f9a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -872,10 +872,6 @@ open class Storage @Inject constructor( return groupDatabase.getAllGroups(includeInactive) } - override suspend fun addOpenGroup(urlAsString: String) { - return openGroupManager.get().addOpenGroup(urlAsString) - } - override fun getOrCreateThreadIdFor(address: Address): Long { return threadDatabase.getOrCreateThreadIdFor(address) } 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 92b3a06c19..e72dd3ff06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -1,18 +1,20 @@ package org.thoughtcrime.securesms.groups -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.open_groups.OpenGroup +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import kotlinx.serialization.json.Json import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor import org.session.libsession.messaging.open_groups.api.CommunityApiRequest import org.session.libsession.messaging.open_groups.api.GetCapsApi +import org.session.libsession.messaging.open_groups.api.GetRoomDetailsApi import org.session.libsession.messaging.open_groups.api.execute -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import javax.inject.Inject import javax.inject.Provider @@ -26,25 +28,53 @@ private const val TAG = "OpenGroupManager" @Singleton class OpenGroupManager @Inject constructor( private val configFactory: ConfigFactoryProtocol, - private val pollerManager: OpenGroupPollerManager, private val lokiAPIDatabase: LokiAPIDatabase, private val communityApiExecutor: CommunityApiExecutor, private val getCapsApi: Provider, + private val getRoomDetailsApiFactory: GetRoomDetailsApi.Factory, + private val communityDatabase: CommunityDatabase, + private val json: Json, ) { - suspend fun add(server: String, room: String, publicKey: String) { + suspend fun add(server: String, room: String, publicKey: String): Unit = supervisorScope { + // Check if the community is already added, if so, we can skip the rest of the process + val alreadyJoined = configFactory.withUserConfigs { + it.userGroups.getCommunityInfo(server, room) + } != null + + if (alreadyJoined) { + Log.d("OpenGroupManager", "Community $server is already added, skipping add process") + return@supervisorScope + } + // Fetch the server's capabilities upfront to see if this server is actually running - // Note: this process is not essential to adding a community, just a nice to have test - // 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 = communityApiExecutor.execute( - CommunityApiRequest( - serverBaseUrl = server, - serverPubKey = publicKey, - api = getCapsApi.get() + val getCaps = async { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = publicKey, + api = getCapsApi.get() + ) + ) + } + + // Fetch room details at the same time also + val getRoomDetails = async { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = publicKey, + api = getRoomDetailsApiFactory.create(room) + ) ) - ) - lokiAPIDatabase.setServerCapabilities(server, caps.capabilities) + } + + val caps = getCaps.await().capabilities + val roomDetails = getRoomDetails.await() + + lokiAPIDatabase.setServerCapabilities(server, caps) + communityDatabase.patchRoomInfo(Address.Community(server, room), + json.encodeToString(OpenGroupApi.RoomInfo(roomDetails))) + // We should be good, now go ahead and add the community to the config configFactory.withMutableUserConfigs { configs -> @@ -56,15 +86,6 @@ class OpenGroupManager @Inject constructor( configs.userGroups.set(community) } - - Log.d(TAG, "Waiting for poller for server $server to be started.") - - // Wait until we have a poller for the server, and then request one poll - pollerManager.pollers - .mapNotNull { it[server] } - .first() - .poller - .manualPollOnce() } fun delete(server: String, room: String) { @@ -73,13 +94,4 @@ class OpenGroupManager @Inject constructor( configs.convoInfoVolatile.eraseCommunity(server, room) } } - - suspend fun addOpenGroup(urlAsString: String) { - val url = urlAsString.toHttpUrlOrNull() ?: return - val server = OpenGroup.getServer(urlAsString) - val room = url.pathSegments.firstOrNull() ?: return - val publicKey = url.queryParameter("public_key") ?: return - - add(server.toString().removeSuffix("/"), room, publicKey) // assume migrated from calling function - } } \ No newline at end of file From 1887376e97f8301b5c8aab6c9811e371a20f56c3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 19 Feb 2026 10:45:19 +1100 Subject: [PATCH 04/10] Abstracting debug info (cherry picked from commit 391cfafa9f96fb15c2789c4a1eb9f97bd0f41c94) --- .../libsession/messaging/open_groups/api/CommunityApi.kt | 4 ++++ .../org/thoughtcrime/securesms/api/server/ServerApi.kt | 7 ++++++- .../thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt index ec515c763a..b81885cbb8 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt @@ -162,6 +162,10 @@ abstract class CommunityApi( super.handleErrorResponse(executorContext, baseUrl, response) } + override fun debugInfo(): String { + return "${this.javaClass.simpleName} for room \"$room\"" + } + class CommunityApiDependencies @Inject constructor( val errorManager: ServerApiErrorManager, val json: Json, diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt index cea9da67c0..bdefe42b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt @@ -45,7 +45,8 @@ abstract class ServerApi( ctx = failureContext, ) - Log.d("ServerApi", "Network error from ${this.javaClass.simpleName} for a Server endpoint ($baseUrl), with status:${response.statusCode} - error: $error") + + Log.e("ServerApi", "Network error for a Server endpoint (${debugInfo()}), with status:${response.statusCode} - error: $error") executorContext.set( key = ServerClientFailureContextKey, @@ -62,6 +63,10 @@ abstract class ServerApi( } } + open fun debugInfo(): String { + return this.javaClass.simpleName + } + abstract suspend fun handleSuccessResponse( executorContext: ApiExecutorContext, diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt index d2b5c8a77e..7296f42ae4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt @@ -48,7 +48,7 @@ abstract class AbstractSnodeApi( ctx = failureContext ) - Log.d("SnodeApi", "Network error for a Snode endpoint ($snode), with status:${code} - error: $error") + Log.e("SnodeApi", "Network error for a Snode endpoint ($snode), with status:${code} - error: $error") ctx.set(SnodeClientFailureKey, failureContext.copy(previousErrorCode = code)) From 571767e997a62bfe6c7c5f47b0466d0b96952d42 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 19 Feb 2026 10:49:07 +1100 Subject: [PATCH 05/10] Adding back url (cherry picked from commit 178ce8cf84485efdebd4e36701705d02016af9c1) --- .../java/org/thoughtcrime/securesms/api/server/ServerApi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt index bdefe42b20..30b80f70bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt @@ -46,7 +46,7 @@ abstract class ServerApi( ) - Log.e("ServerApi", "Network error for a Server endpoint (${debugInfo()}), with status:${response.statusCode} - error: $error") + Log.e("ServerApi", "Network error for a Server endpoint: \"$baseUrl\" (${debugInfo()}), with status:${response.statusCode} - error: $error") executorContext.set( key = ServerClientFailureContextKey, From 880dce1e565da7e55b5414777707b883254d002c Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:42:57 +1100 Subject: [PATCH 06/10] Tweaking poller logic (#1972) (cherry picked from commit 7241737301537ad56440a6c52560b431daa91991) --- .../sending_receiving/pollers/BasePoller.kt | 158 +++++++++++------- .../pollers/OpenGroupPoller.kt | 4 +- .../pollers/OpenGroupPollerManager.kt | 3 +- .../securesms/groups/GroupPollerManager.kt | 3 +- .../notifications/BackgroundPollWorker.kt | 2 +- 5 files changed, 103 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt index 424ce51a43..23b13b4a21 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt @@ -2,7 +2,10 @@ package org.session.libsession.messaging.sending_receiving.pollers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -10,13 +13,17 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.selects.selectUnbiased import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private typealias PollRequestCallback = SendChannel> /** * Base class for pollers that perform periodic polling operations. These poller will: @@ -30,11 +37,12 @@ import kotlin.time.Instant */ abstract class BasePoller( private val networkConnectivity: NetworkConnectivity, - appVisibilityManager: AppVisibilityManager, + private val appVisibilityManager: AppVisibilityManager, private val scope: CoroutineScope, ) { protected val logTag: String = this::class.java.simpleName - private val pollMutex = Mutex() + + private val manualPollRequestSender: SendChannel> private val mutablePollState = MutableStateFlow>(PollState.Idle) @@ -44,43 +52,77 @@ abstract class BasePoller( val pollState: StateFlow> get() = mutablePollState init { + val manualPollRequestChannel = Channel>() + + manualPollRequestSender = manualPollRequestChannel + scope.launch { var numConsecutiveFailures = 0 + var nextRoutinePollAt: TimeMark? = null while (true) { - // Wait until the app is in the foreground and we have network connectivity - combine( - appVisibilityManager.isAppVisible.filter { visible -> - if (visible) { - true - } else { - Log.d(logTag, "Polling paused - app in background") - false - } - }, - networkConnectivity.networkAvailable.filter { hasNetwork -> - if (hasNetwork) { - true - } else { - Log.d(logTag, "Polling paused - no network connectivity") - false - } - }, - transform = { _, _ -> } - ).first() - - try { - pollOnce("routine") - numConsecutiveFailures = 0 - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { - numConsecutiveFailures += 1 + val waitForRoutinePollDeferred = waitForRoutinePoll(nextRoutinePollAt) + + val (pollReason, callback) = selectUnbiased { + manualPollRequestChannel.onReceive { callback -> + "manual" to callback + } + + waitForRoutinePollDeferred.onAwait { + "routine" to null + } } + // Clean up the deferred + waitForRoutinePollDeferred.cancel() + + val result = runCatching { + pollOnce(pollReason) + }.onSuccess { numConsecutiveFailures = 0 } + .onFailure { + if (it is CancellationException) throw it + numConsecutiveFailures += 1 + } + + // Must use trySend as we shouldn't be waiting or responsible for + // the manual request (potential) ill-setup. + callback?.trySend(result) + val nextPollSeconds = nextPollDelaySeconds(numConsecutiveFailures) - Log.d(logTag, "Next poll in ${nextPollSeconds}s") - delay(nextPollSeconds * 1000L) + nextRoutinePollAt = TimeSource.Monotonic.markNow().plus(nextPollSeconds.seconds) + } + } + } + + private fun waitForRoutinePoll(minStartAt: TimeMark?): Deferred { + return scope.async { + combine( + appVisibilityManager.isAppVisible.filter { visible -> + if (visible) { + true + } else { + Log.d(logTag, "Polling paused - app in background") + false + } + }, + networkConnectivity.networkAvailable.filter { hasNetwork -> + if (hasNetwork) { + true + } else { + Log.d(logTag, "Polling paused - no network connectivity") + false + } + }, + { _, _ -> } + ).first() + + // At this point, the criteria for routine poll are all satisfied. + + // If we are told we can only start executing from a time, wait until that. + val delayMillis = minStartAt?.elapsedNow()?.let { -it.inWholeMilliseconds } + if (delayMillis != null && delayMillis > 0) { + Log.d(logTag, "Delay next poll for ${delayMillis}ms") + delay(delayMillis) } } } @@ -110,28 +152,26 @@ abstract class BasePoller( protected abstract suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): T private suspend fun pollOnce(reason: String): T { - pollMutex.withLock { - val lastState = mutablePollState.value - mutablePollState.value = - PollState.Polling(reason, lastPolledResult = lastState.lastPolledResult) - Log.d(logTag, "Start $reason polling") - val result = runCatching { - doPollOnce(isFirstPollSinceApoStarted = lastState is PollState.Idle) - } + val lastState = mutablePollState.value + mutablePollState.value = + PollState.Polling(reason, lastPolledResult = lastState.lastPolledResult) + Log.d(logTag, "Start $reason polling") + val result = runCatching { + doPollOnce(isFirstPollSinceAppStarted = lastState is PollState.Idle) + } - if (result.isSuccess) { - Log.d(logTag, "$reason polling succeeded") - } else if (result.exceptionOrNull() !is CancellationException) { - Log.e(logTag, "$reason polling failed", result.exceptionOrNull()) - } + if (result.isSuccess) { + Log.d(logTag, "$reason polling succeeded") + } else if (result.exceptionOrNull() !is CancellationException) { + Log.e(logTag, "$reason polling failed", result.exceptionOrNull()) + } - mutablePollState.value = PollState.Polled( - at = Clock.System.now(), - result = result, - ) + mutablePollState.value = PollState.Polled( + at = Clock.System.now(), + result = result, + ) - return result.getOrThrow() - } + return result.getOrThrow() } /** @@ -143,15 +183,9 @@ abstract class BasePoller( * * This method will throw if the polling operation fails. */ suspend fun manualPollOnce(): T { - val resultChannel = Channel>() - - scope.launch { - resultChannel.trySend(runCatching { - pollOnce("manual") - }) - } - - return resultChannel.receive().getOrThrow() + val callback = Channel>(capacity = 1) + manualPollRequestSender.send(callback) + return callback.receive().getOrThrow() } 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 dbaa4e6e32..363b035a7f 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 @@ -4,8 +4,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.json.Json @@ -97,7 +97,7 @@ class OpenGroupPoller @AssistedInject constructor( return } - coroutineScope { + supervisorScope { var caps = storage.getServerCapabilities(server) if (caps == null) { val fetched = communityApiExecutor.execute( 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 04709336a2..ea33a6460d 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 @@ -3,6 +3,7 @@ package org.session.libsession.messaging.sending_receiving.pollers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -67,7 +68,7 @@ class OpenGroupPollerManager @Inject constructor( } else { val newPollerStates = value.associateWith { baseUrl -> acc[baseUrl] ?: run { - val scope = CoroutineScope(Dispatchers.Default) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) Log.d(TAG, "Creating new poller for $baseUrl") PollerHandle( poller = pollerFactory.create(baseUrl, scope, pollerSemaphore), 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 3efa436ade..6fc473b47f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel @@ -110,7 +111,7 @@ class GroupPollerManager @Inject constructor( if (poller == null) { Log.d(TAG, "Starting poller for $groupId") - val scope = CoroutineScope(Dispatchers.Default) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) poller = GroupPollerHandle( poller = pollFactory.create( scope = scope, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 03de22eef4..9901aa746a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -53,7 +53,7 @@ class BackgroundPollWorker @AssistedInject constructor( val interval = 15.minutes val builder = PeriodicWorkRequestBuilder(interval.inWholeSeconds, TimeUnit.SECONDS) builder.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) - .setInitialDelay(interval.inWholeSeconds, TimeUnit.SECONDS) + .setInitialDelay(30, TimeUnit.SECONDS) val dataBuilder = Data.Builder() dataBuilder.putStringArray(REQUEST_TARGETS, targets.map { it.name }.toTypedArray()) From 0841abc708dfa57367f826691e4867a232e3548b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 19 Feb 2026 17:17:02 +1100 Subject: [PATCH 07/10] The background poller can never start if the user swipe off or force stops the app (cherry picked from commit de241253ca20a2e19f320a41af9627a7c410c945) --- .../notifications/BackgroundPollManager.kt | 13 ++++++++----- .../securesms/notifications/BackgroundPollWorker.kt | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt index 52aa16d28c..7781b6a6f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt @@ -5,10 +5,12 @@ import android.app.Application import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.work.ExistingPeriodicWorkPolicy import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.AuthAwareComponent import org.thoughtcrime.securesms.auth.LoggedInState @@ -27,16 +29,17 @@ class BackgroundPollManager @Inject constructor( private val appVisibilityManager: AppVisibilityManager, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + Log.i(TAG, "Scheduling background polling work (As we are logged in).") + BackgroundPollWorker.schedulePeriodic(application, ExistingPeriodicWorkPolicy.KEEP) + appVisibilityManager.isAppVisible + .drop(1) .debounce(1_000L) .distinctUntilChanged() .collectLatest { isAppVisible -> if (!isAppVisible) { - Log.i(TAG, "Scheduling background polling work.") - BackgroundPollWorker.schedulePeriodic(application) - } else { - Log.i(TAG, "Cancelling background polling work.") - BackgroundPollWorker.cancelPeriodic(application) + Log.i(TAG, "Scheduling background polling work (from app visibility > replacing existing one).") + BackgroundPollWorker.schedulePeriodic(application, ExistingPeriodicWorkPolicy.REPLACE) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 9901aa746a..a09189e6b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -48,7 +48,7 @@ class BackgroundPollWorker @AssistedInject constructor( private const val TAG = "BackgroundPollWorker" private const val REQUEST_TARGETS = "REQUEST_TARGETS" - fun schedulePeriodic(context: Context, targets: Collection = Target.entries) { + fun schedulePeriodic(context: Context, policy: ExistingPeriodicWorkPolicy, targets: Collection = Target.entries) { Log.v(TAG, "Scheduling periodic work.") val interval = 15.minutes val builder = PeriodicWorkRequestBuilder(interval.inWholeSeconds, TimeUnit.SECONDS) @@ -62,7 +62,7 @@ class BackgroundPollWorker @AssistedInject constructor( val workRequest = builder.build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( TAG, - ExistingPeriodicWorkPolicy.REPLACE, + policy, workRequest ) } From dac869305a04da22723fc9b16378fd5592bd089e Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:19:24 +1100 Subject: [PATCH 08/10] Bump version to 1.31.3 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9146fe17c1..5d7086d4d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 439 -val canonicalVersionName = "1.31.2" +val canonicalVersionCode = 440 +val canonicalVersionName = "1.31.3" val postFixSize = 10 val abiPostFix = mapOf( From 4515c2198e8552f7fd3162c8632270212ed7c0f1 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:31:03 +1100 Subject: [PATCH 09/10] Fix parameter naming --- .../messaging/sending_receiving/pollers/BasePoller.kt | 4 ++-- .../messaging/sending_receiving/pollers/OpenGroupPoller.kt | 2 +- .../messaging/sending_receiving/pollers/Poller.kt | 6 +++--- .../java/org/thoughtcrime/securesms/groups/GroupPoller.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt index 23b13b4a21..ca11e193cc 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt @@ -146,10 +146,10 @@ abstract class BasePoller( /** * Performs a single polling operation. A failed poll should throw an exception. * - * @param isFirstPollSinceApoStarted True if this is the first poll since the app started. + * @param isFirstPollSinceAppStarted True if this is the first poll since the app started. * @return The result of the polling operation. */ - protected abstract suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): T + protected abstract suspend fun doPollOnce(isFirstPollSinceAppStarted: Boolean): T private suspend fun pollOnce(reason: String): T { val lastState = mutablePollState.value 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 363b035a7f..0a8df17faf 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 @@ -83,7 +83,7 @@ class OpenGroupPoller @AssistedInject constructor( * * @return A list of rooms that were polled. */ - override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): Unit = pollerSemaphore.withPermit { + override suspend fun doPollOnce(isFirstPollSinceAppStarted: Boolean): Unit = pollerSemaphore.withPermit { val allCommunities = configFactory.withUserConfigs { it.userGroups.allCommunityInfo() } val rooms = allCommunities diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 7098b775a2..2decc3aca3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -66,9 +66,9 @@ class Poller @AssistedInject constructor( fun create(scope: CoroutineScope): Poller } - override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean) { + override suspend fun doPollOnce(isFirstPollSinceAppStarted: Boolean) { // Migrate to multipart config when needed - if (isFirstPollSinceApoStarted && !preferences.migratedToMultiPartConfig) { + if (isFirstPollSinceAppStarted && !preferences.migratedToMultiPartConfig) { val allConfigNamespaces = intArrayOf(Namespace.USER_PROFILE(), Namespace.USER_GROUPS(), Namespace.CONTACTS(), @@ -88,7 +88,7 @@ class Poller @AssistedInject constructor( // When we are only just starting to set up the account, we want to poll only the user // profile config so the user can see their name/avatar ASAP. Once this is done, we // will do a full poll immediately. - val pollOnlyUserProfileConfig = isFirstPollSinceApoStarted && + val pollOnlyUserProfileConfig = isFirstPollSinceAppStarted && configFactory.withUserConfigs { it.userProfile.activeHashes().isEmpty() } poll( 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 927509440e..f802520185 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -64,7 +64,7 @@ class GroupPoller @AssistedInject constructor( val groupExpired: Boolean? ) - override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): GroupPollResult = pollSemaphore.withPermit { + override suspend fun doPollOnce(isFirstPollSinceAppStarted: Boolean): GroupPollResult = pollSemaphore.withPermit { var groupExpired: Boolean? = null val result = runCatching { From 105029d274746674512c72728fd712933859832c Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:21:02 +1100 Subject: [PATCH 10/10] Fix various incorrect coroutine usage (#1980) --- .../sending_receiving/MessageSender.kt | 2 +- .../pollers/OpenGroupPoller.kt | 35 ++++++++++++++++--- .../pollers/OpenGroupPollerManager.kt | 19 ++++------ .../sending_receiving/pollers/Poller.kt | 3 ++ .../securesms/configs/ConfigUploader.kt | 5 +++ 5 files changed, 45 insertions(+), 19 deletions(-) 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 2777700af3..e98e37e530 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 @@ -244,7 +244,7 @@ class MessageSender @Inject constructor( } // One-on-One Chats & Closed Groups - private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) = supervisorScope { + private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) { // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) 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 0a8df17faf..ffb60f7845 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 @@ -3,8 +3,10 @@ package org.session.libsession.messaging.sending_receiving.pollers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -111,6 +113,8 @@ class OpenGroupPoller @AssistedInject constructor( caps = fetched.capabilities } + val allTasks = mutableListOf>>() + for (room in rooms) { val address = Address.Community(serverUrl = server, room = room) val latestRoomPollInfo = communityDatabase.getRoomInfo(address) @@ -118,7 +122,7 @@ class OpenGroupPoller @AssistedInject constructor( val lastMessageServerId = storage.getLastMessageServerID(room, server) // Poll room info - launch { + allTasks += "polling room info" to async { val roomInfo = communityApiExecutor.execute( CommunityApiRequest( serverBaseUrl = server, @@ -137,7 +141,7 @@ class OpenGroupPoller @AssistedInject constructor( } // Poll room messages - launch { + allTasks += "polling room messages" to async { val messages = communityApiExecutor.execute( CommunityApiRequest( serverBaseUrl = server, @@ -158,7 +162,7 @@ class OpenGroupPoller @AssistedInject constructor( // We'll only poll our index if we are accepting community requests if (storage.isCheckingCommunityRequests()) { // Poll inbox messages - launch { + allTasks += "polling inbox messages" to async { val inboxMessages = communityApiExecutor.execute( CommunityApiRequest( serverBaseUrl = server, @@ -175,7 +179,7 @@ class OpenGroupPoller @AssistedInject constructor( } // Poll outbox messages regardless because these are messages we sent - launch { + allTasks += "polling outbox messages" to async { val outboxMessages = communityApiExecutor.execute( CommunityApiRequest( serverBaseUrl = server, @@ -190,6 +194,27 @@ class OpenGroupPoller @AssistedInject constructor( handleOutboxMessages(messages = outboxMessages) } } + + /** + * Await on all tasks and gather the first exception with the rest errors suppressed. + */ + val accumulatedError = allTasks + .fold(null) { acc: Throwable?, (taskName, deferred) -> + val err = runCatching { deferred.await() } + .onFailure { if (it is CancellationException) throw it } + .exceptionOrNull() + ?.let { RuntimeException("Error $taskName", it) } + + if (err != null) { + acc?.apply { addSuppressed(err) } ?: err + } else { + acc + } + } + + if (accumulatedError != null) { + throw accumulatedError + } } } 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 ea33a6460d..47a978cf71 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 @@ -1,9 +1,10 @@ package org.session.libsession.messaging.sending_receiving.pollers -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -12,8 +13,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan 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 @@ -98,17 +97,11 @@ class OpenGroupPollerManager @Inject constructor( suspend fun pollAllOpenGroupsOnce() { Log.d(TAG, "Polling all open groups once") supervisorScope { - pollers.value.map { (server, handle) -> - handle.pollerScope.launch { - runCatching { - handle.poller.manualPollOnce() - }.onFailure { - if (it !is CancellationException) { - Log.e(TAG, "Error polling open group $server", it) - } - } + pollers.value.map { (_, handle) -> + async { + handle.poller.manualPollOnce() } - }.joinAll() + }.awaitAll() } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 2decc3aca3..69b8e7bcb0 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.sending_receiving.pollers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.launch @@ -267,6 +268,8 @@ class Poller @AssistedInject constructor( ) ) } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(logTag, "Error while extending TTL for hashes", e) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index 4a051ee671..a16d4a5f5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.configs +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async @@ -114,6 +115,8 @@ class ConfigUploader @Inject constructor( pushUserConfigChangesIfNeeded() } } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to push user configs", e) } } @@ -142,6 +145,8 @@ class ConfigUploader @Inject constructor( pushGroupConfigsChangesIfNeeded(groupId) } } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to push group configs", e) } }