diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 270b30fe43..273a30252c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.notifications.BackgroundPollManager; import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.notifications.PushRegistrationHandler; +import org.thoughtcrime.securesms.notifications.PushRegistrationManager; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.KeyCachingService; @@ -160,7 +160,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject ConfigFactory configFactory; @Inject LastSentTimestampCache lastSentTimestampCache; @Inject VersionDataFetcher versionDataFetcher; - @Inject PushRegistrationHandler pushRegistrationHandler; + @Inject PushRegistrationManager pushRegistrationManager; // Exists here only to start upon app starts @Inject TokenFetcher tokenFetcher; @Inject GroupManagerV2 groupManagerV2; @Inject SSKEnvironment.ProfileManagerProtocol profileManager; @@ -299,7 +299,6 @@ public void onCreate() { HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); snodeClock.start(); - pushRegistrationHandler.run(); configUploader.start(); removeGroupMemberHandler.start(); destroyedGroupSync.start(); 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 cd143d488f..9edcbd7bca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -17,6 +17,7 @@ import network.loki.messenger.libsession_util.MutableContacts import network.loki.messenger.libsession_util.MutableConversationVolatileConfig import network.loki.messenger.libsession_util.MutableUserGroupsConfig import network.loki.messenger.libsession_util.MutableUserProfile +import network.loki.messenger.libsession_util.ReadableGroupKeysConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.util.BaseCommunityInfo @@ -299,6 +300,20 @@ class ConfigFactory @Inject constructor( } } + override fun snapshotGroupAuth(groupId: AccountId): SwarmAuth? { + val group = getGroup(groupId) ?: return null + if (group.hasAdminKey()) { + return OwnedSwarmAuth.ofClosedGroup(groupId, group.adminKey!!) + } + + val token = group.authData ?: return null + val keyAccess = StaticGroupKeyAccess(withGroupConfigs(groupId) { + it.groupKeys.copy() + }) + + return GroupSubAccountSwarmAuth(groupId, keyAccess, token) + } + override fun decryptForUser( encoded: ByteArray, domain: String, @@ -466,7 +481,7 @@ class ConfigFactory @Inject constructor( return if (group.adminKey != null) { OwnedSwarmAuth.ofClosedGroup(groupId, group.adminKey!!) } else if (group.authData != null) { - GroupSubAccountSwarmAuth(groupId, this, group.authData!!) + GroupSubAccountSwarmAuth(groupId, ConfigFactoryGroupKeyAccess(groupId), group.authData!!) } else { null } @@ -482,17 +497,38 @@ class ConfigFactory @Inject constructor( } } + // Provides access to the group keys + private interface GroupKeysAccess { + fun accessKeys(cb: (ReadableGroupKeysConfig) -> T): T + } + + // Provides access to the group keys for a specific group from the ConfigFactory + private inner class ConfigFactoryGroupKeyAccess(val groupId: AccountId) : GroupKeysAccess { + override fun accessKeys(cb: (ReadableGroupKeysConfig) -> T): T { + return withGroupConfigs(groupId) { + cb(it.groupKeys) + } + } + } + + // Provides access to a group key config directly + private class StaticGroupKeyAccess(val groupKeysConfig: ReadableGroupKeysConfig) : GroupKeysAccess { + override fun accessKeys(cb: (ReadableGroupKeysConfig) -> T): T { + return cb(groupKeysConfig) + } + } + private class GroupSubAccountSwarmAuth( override val accountId: AccountId, - val factory: ConfigFactory, + private val keyAccess: GroupKeysAccess, val authData: ByteArray, ) : SwarmAuth { override val ed25519PublicKeyHex: String? get() = null override fun sign(data: ByteArray): Map { - return factory.withGroupConfigs(accountId) { - val auth = it.groupKeys.subAccountSign(data, authData) + return keyAccess.accessKeys { + val auth = it.subAccountSign(data, authData) buildMap { put("subaccount", auth.subAccount) put("subaccount_sig", auth.subAccountSig) @@ -502,8 +538,8 @@ class ConfigFactory @Inject constructor( } override fun signForPushRegistry(data: ByteArray): Map { - return factory.withGroupConfigs(accountId) { - val auth = it.groupKeys.subAccountSign(data, authData) + return keyAccess.accessKeys { + val auth = it.subAccountSign(data, authData) buildMap { put("subkey_tag", auth.subAccount) put("signature", auth.signature) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt deleted file mode 100644 index 6c68e9c26d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ /dev/null @@ -1,181 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.launch -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SwarmAuth -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import javax.inject.Inject - -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. - */ -class PushRegistrationHandler -@Inject -constructor( - private val pushRegistry: PushRegistryV2, - private val configFactory: ConfigFactory, - private val preferences: TextSecurePreferences, - private val storage: Storage, - private val tokenFetcher: TokenFetcher, -) { - @OptIn(DelicateCoroutinesApi::class) - private val scope: CoroutineScope = GlobalScope - - private var job: Job? = null - - @OptIn(FlowPreview::class) - fun run() { - require(job == null) { "Job is already running" } - - job = scope.launch(Dispatchers.Default) { - combine( - (configFactory.configUpdateNotifications as Flow) - .debounce(500L) - .onStart { emit(Unit) }, - IdentityKeyUtil.CHANGES.onStart { emit(Unit) }, - preferences.pushEnabled, - tokenFetcher.token, - ) { _, _, enabled, token -> - if (!enabled || token.isNullOrEmpty()) { - return@combine emptyMap() - } - - val userAuth = - storage.userAuth ?: return@combine emptyMap() - getGroupSubscriptions( - token = token - ) + mapOf( - SubscriptionKey(userAuth.accountId, token) to Subscription(userAuth, listOf(Namespace.DEFAULT())) - ) - } - .scan, Pair, Map>?>( - null - ) { acc, current -> - val prev = acc?.second.orEmpty() - prev to current - } - .filterNotNull() - .collect { (prev, current) -> - val addedAccountIds = current.keys - prev.keys - val removedAccountIDs = prev.keys - current.keys - if (addedAccountIds.isNotEmpty()) { - Log.d(TAG, "Adding ${addedAccountIds.size} new subscriptions") - } - - if (removedAccountIDs.isNotEmpty()) { - Log.d(TAG, "Removing ${removedAccountIDs.size} subscriptions") - } - - val deferred = mutableListOf>() - - addedAccountIds.mapTo(deferred) { key -> - val subscription = current.getValue(key) - async { - try { - pushRegistry.register( - token = key.token, - swarmAuth = subscription.auth, - namespaces = subscription.namespaces.toList() - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to register for push notification", e) - } - } - } - - removedAccountIDs.mapTo(deferred) { key -> - val subscription = prev.getValue(key) - async { - try { - pushRegistry.unregister( - token = key.token, - swarmAuth = subscription.auth, - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to unregister for push notification", e) - } - } - } - - deferred.awaitAll() - } - } - } - - private fun getGroupSubscriptions( - token: String - ): Map { - return buildMap { - val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - .filter { it.shouldPoll } - - val namespaces = listOf( - Namespace.GROUP_MESSAGES(), - Namespace.GROUP_INFO(), - Namespace.GROUP_MEMBERS(), - Namespace.GROUP_KEYS(), - Namespace.REVOKED_GROUP_MESSAGES(), - ) - - for (group in groups) { - val adminKey = group.adminKey - if (adminKey != null && adminKey.isNotEmpty()) { - put( - SubscriptionKey(group.groupAccountId, token), - Subscription( - auth = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey), - namespaces = namespaces - ) - ) - continue - } - - val authData = group.authData - if (authData != null && authData.isNotEmpty()) { - val subscription = configFactory.getGroupAuth(group.groupAccountId) - ?.let { - Subscription( - auth = it, - namespaces = namespaces - ) - } - - if (subscription != null) { - put(SubscriptionKey(group.groupAccountId, token), subscription) - } - } - } - } - } - - private data class SubscriptionKey(val accountId: AccountId, val token: String) - private data class Subscription(val auth: SwarmAuth, val namespaces: List) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationManager.kt new file mode 100644 index 0000000000..4df6961738 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationManager.kt @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.notifications + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.InternetConnectivity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PushRegistrationManager @Inject constructor( + private val pushRegistry: PushRegistryV2, + preferences: TextSecurePreferences, + tokenFetcher: TokenFetcher, + private val configFactory: ConfigFactory, + private val clock: SnodeClock, + connectivity: InternetConnectivity, + private val storage: StorageProtocol, +) { + private val subscriptions = + // Produces a list of account that we should subscribe to. This shall be the source of truth that: + // 1. Every account in this list must be subscribed to. + // 2. Everything not in this list must NOT be subscribed to. + combine( + preferences.watchLocalNumber(), + preferences.pushEnabled, + configFactory.configUpdateNotifications.filter { it is ConfigUpdateNotification.UserConfigsMerged || it == ConfigUpdateNotification.UserConfigsModified }, + tokenFetcher.token.filterNotNull() // Must wait for the token to be available. + ) { localNumber, pushEnabled, _, token -> + if (pushEnabled && localNumber != null) { + val accounts = buildSet { + add(AccountId(localNumber)) + configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + .asSequence() + .filter { it.shouldPoll } + .mapTo(this) { it.groupAccountId } + } + + accounts to token + } else { + emptySet() to token + } + } + .distinctUntilChanged() + + // This scan: for each account in the list, make sure we subscribe to its push service. + // For accounts that are no longer in the list, we unsubscribe. + .scan(emptyMap()) { previous, (updated, token) -> + supervisorScope { + // Wait for the internet to become available + connectivity.networkAvailable.first { it } + + // Go through the old list and unsubscribe from accounts that are no longer in the new list. + for ((account, subscription) in previous) { + if (account !in updated) { + launch { + unregister(subscription) + } + } + } + + val registrations = updated + .asSequence() + .map { account -> + async { + var subscription = previous[account] + if (subscription == null || subscription.token != token) { + subscription = runCatching { register(account, token) } + .onFailure { + Log.w(TAG, "Failed to register push for $account", it) + } + .getOrNull() + } + + account to subscription + } + } + .toList() + + registrations.awaitAll() + .filter { it.second != null } + .associate { (id, sub) -> id to sub!! } + } + } + .stateIn(GlobalScope, SharingStarted.Eagerly, emptyMap()) + + private suspend fun register(account: AccountId, token: String): Subscription { + val auth: SwarmAuth + val namespaces: List + + if (account.prefix == IdPrefix.GROUP) { + auth = checkNotNull(configFactory.snapshotGroupAuth(account)) { "Group found for $account" } + namespaces = listOf( + Namespace.GROUP_INFO(), + Namespace.GROUP_KEYS(), + Namespace.GROUP_MEMBERS(), + Namespace.GROUP_MESSAGES(), + Namespace.REVOKED_GROUP_MESSAGES(), + ) + } else { + auth = checkNotNull(storage.userAuth) { "User auth not found" } + namespaces = listOf(Namespace.DEFAULT()) + } + + pushRegistry.register(token, auth, namespaces) + return Subscription(auth, token, clock.currentTimeMills()) + } + + private suspend fun unregister(subscription: Subscription) { + pushRegistry.unregister(subscription.token, subscription.auth) + } + + private data class Subscription( + val auth: SwarmAuth, + val token: String, + val subscribedAtMills: Long, + ) + + companion object { + private const val TAG = "PushRegistrationManager" + } +} \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt index 12e827e510..17e4c60a4d 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt @@ -13,7 +13,6 @@ private const val TAG = "FirebasePushNotificationService" class FirebasePushService : FirebaseMessagingService() { @Inject lateinit var pushReceiver: PushReceiver - @Inject lateinit var handler: PushRegistrationHandler @Inject lateinit var tokenFetcher: TokenFetcher override fun onNewToken(token: String) { diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt index fb2404ca20..d039733102 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -16,24 +16,18 @@ import org.session.libsignal.utilities.Namespace import java.io.Closeable import java.util.Stack -sealed class Config(initialPointer: Long): Closeable { - var pointer = initialPointer - private set - +sealed class Config(val pointer: Long) { init { check(pointer != 0L) { "Pointer is null" } } abstract fun namespace(): Int - private external fun free() - - final override fun close() { - if (pointer != 0L) { - free() - pointer = 0L - } + fun finalize() { + free() } + + private external fun free() } interface ReadableConfig { @@ -459,18 +453,24 @@ interface ReadableGroupKeysConfig { fun getSubAccountToken(sessionId: AccountId, canWrite: Boolean = true, canDelete: Boolean = false): ByteArray fun currentGeneration(): Int fun size(): Int + fun copy(): ReadableGroupKeysConfig } interface MutableGroupKeysConfig : ReadableGroupKeysConfig { fun makeSubAccount(sessionId: AccountId, canWrite: Boolean = true, canDelete: Boolean = false): ByteArray fun loadKey(message: ByteArray, hash: String, timestampMs: Long): Boolean fun loadAdminKey(adminKey: ByteArray) + + override fun copy(): MutableGroupKeysConfig } class GroupKeysConfig private constructor( pointer: Long, private val info: GroupInfoConfig, - private val members: GroupMembersConfig + private val members: GroupMembersConfig, + private val userSecretKey: ByteArray, + private val groupPublicKey: ByteArray, + private val groupAdminKey: ByteArray? ): ConfigSig(pointer), MutableGroupKeysConfig { companion object { private external fun newInstance( @@ -491,16 +491,19 @@ class GroupKeysConfig private constructor( info: GroupInfoConfig, members: GroupMembersConfig ) : this( - newInstance( - userSecretKey, - groupPublicKey, - groupAdminKey, - initialDump, - info.pointer, - members.pointer + pointer = newInstance( + userSecretKey = userSecretKey, + groupPublicKey = groupPublicKey, + groupSecretKey = groupAdminKey, + initialDump = initialDump, + infoPtr = info.pointer, + members = members.pointer ), - info, - members + info = info, + members = members, + userSecretKey = userSecretKey, + groupPublicKey = groupPublicKey, + groupAdminKey = groupAdminKey ) override fun namespace() = Namespace.GROUP_KEYS() @@ -549,6 +552,24 @@ class GroupKeysConfig private constructor( external fun admin(): Boolean external override fun size(): Int + override fun copy(): GroupKeysConfig { + return GroupKeysConfig( + pointer = newInstance( + userSecretKey = userSecretKey, + groupPublicKey = groupPublicKey, + groupSecretKey = groupAdminKey, + initialDump = dump(), + infoPtr = info.pointer, + members = members.pointer + ), + info = info, + members = members, + userSecretKey = userSecretKey, + groupPublicKey = groupPublicKey, + groupAdminKey = groupAdminKey + ) + } + data class SwarmAuth( val subAccount: String, val subAccountSig: String, diff --git a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index f14e1695f9..42d05bfb0a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -91,6 +91,8 @@ interface ConfigFactoryProtocol { fun deleteGroupConfigs(groupId: AccountId) + // Take a snapshot of given group's auth data for signing purpose + fun snapshotGroupAuth(groupId: AccountId): SwarmAuth? } class ConfigMessage(