11package org.thoughtcrime.securesms.notifications
22
33import android.content.Context
4+ import androidx.work.WorkInfo
5+ import androidx.work.WorkManager
46import dagger.hilt.android.qualifiers.ApplicationContext
57import kotlinx.coroutines.CancellationException
68import kotlinx.coroutines.CoroutineScope
@@ -10,8 +12,9 @@ import kotlinx.coroutines.Job
1012import kotlinx.coroutines.flow.Flow
1113import kotlinx.coroutines.flow.combine
1214import kotlinx.coroutines.flow.debounce
15+ import kotlinx.coroutines.flow.distinctUntilChanged
1316import kotlinx.coroutines.flow.onStart
14- import kotlinx.coroutines.flow.scan
17+ import kotlinx.coroutines.guava.await
1518import kotlinx.coroutines.launch
1619import kotlinx.coroutines.supervisorScope
1720import org.session.libsession.database.userAuth
@@ -25,28 +28,28 @@ import org.thoughtcrime.securesms.database.Storage
2528import org.thoughtcrime.securesms.dependencies.ConfigFactory
2629import org.thoughtcrime.securesms.dependencies.ManagerScope
2730import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent
31+ import java.security.MessageDigest
2832import javax.inject.Inject
2933import javax.inject.Singleton
3034
31- private const val TAG = " PushRegistrationHandler"
32-
3335/* *
34- * A class that listens to the config, user's preference, token changes and
35- * register/unregister push notification accordingly.
36+ * PN registration source of truth using per-account periodic workers.
37+ *
38+ * Periodic workers must be created with tags:
39+ * - "pn-register-periodic"
40+ * - "pn-acc-<hexAccountId>"
41+ * - "pn-tfp-<tokenFingerprint>"
3642 *
37- * This class DOES NOT handle the legacy groups push notification.
3843 */
3944@Singleton
40- class PushRegistrationHandler
41- @Inject
42- constructor (
45+ class PushRegistrationHandler @Inject constructor(
4346 private val configFactory : ConfigFactory ,
4447 private val preferences : TextSecurePreferences ,
4548 private val tokenFetcher : TokenFetcher ,
46- @param: ApplicationContext private val context: Context ,
49+ @ApplicationContext private val context : Context ,
4750 private val registry : PushRegistryV2 ,
4851 private val storage : Storage ,
49- @param: ManagerScope private val scope: CoroutineScope
52+ @ManagerScope private val scope : CoroutineScope
5053) : OnAppStartupComponent {
5154
5255 private var job: Job ? = null
@@ -62,83 +65,171 @@ constructor(
6265 .onStart { emit(Unit ) },
6366 preferences.watchLocalNumber(),
6467 preferences.pushEnabled,
65- tokenFetcher.token,
66- ) { _, myAccountId, enabled, token ->
67- if (! enabled || myAccountId == null || storage.getUserED25519KeyPair() == null || token.isNullOrEmpty()) {
68- return @combine emptySet<SubscriptionKey >()
68+ tokenFetcher.token
69+ ) { _, _, enabled, token ->
70+ val desired =
71+ if (enabled && hasCoreIdentity())
72+ desiredSubscriptions()
73+ else emptySet()
74+ Triple (enabled, token, desired)
75+ }
76+ .distinctUntilChanged()
77+ .collect { (pushEnabled, token, desiredIds) ->
78+ try {
79+ reconcileWithWorkManager(pushEnabled, token, desiredIds)
80+ } catch (t: Throwable ) {
81+ Log .e(TAG , " Reconciliation failed" , t)
6982 }
83+ }
84+ }
85+ }
86+
87+ private suspend fun reconcileWithWorkManager (
88+ pushEnabled : Boolean ,
89+ token : String? ,
90+ activeAccounts : Set <AccountId >
91+ ) {
92+ val wm = WorkManager .getInstance(context)
93+
94+ // Read existing push periodic workers and parse (AccountId, tokenFingerprint) from tags.
95+ val periodicInfos = wm.getWorkInfosByTag(TAG_PERIODIC ).await()
96+ .filter { it.state != WorkInfo .State .CANCELLED && it.state != WorkInfo .State .FAILED }
7097
71- setOf (SubscriptionKey (AccountId (myAccountId), token)) + getGroupSubscriptions(token)
98+ Log .d(TAG , " We currently have ${periodicInfos.size} push periodic workers" )
99+
100+ val accountsAlreadyRegistered: Map <AccountId , String > = buildMap {
101+ for (info in periodicInfos) {
102+ val id = parseAccountId(info) ? : continue
103+ val token = parseTokenFingerprint(info) ? : continue
104+ put(id, token)
72105 }
73- .scan(emptySet<SubscriptionKey >() to emptySet<SubscriptionKey >()) { acc, current ->
74- acc.second to current
75- }
76- .collect { (prev, current) ->
77- val added = current - prev
78- val removed = prev - current
79- if (added.isNotEmpty()) {
80- Log .d(TAG , " Adding ${added.size} new subscriptions" )
81- }
106+ }
82107
83- if (removed.isNotEmpty()) {
84- Log .d(TAG , " Removing ${removed.size} subscriptions" )
108+ // If push disabled or identity missing → cancel all and try to deregister.
109+ if (! pushEnabled || ! hasCoreIdentity()) {
110+ val toCancel = accountsAlreadyRegistered.keys
111+ if (toCancel.isNotEmpty()) {
112+ Log .d(TAG , " Push disabled/identity missing; cancelling ${toCancel.size} PN periodic works" )
113+ }
114+ supervisorScope {
115+ toCancel.forEach { id ->
116+ launch {
117+ PushRegistrationWorker .cancelAll(context, id)
118+ tryUnregister(token, id)
85119 }
120+ }
121+ }
122+ return
123+ }
86124
87- for (key in added) {
88- PushRegistrationWorker .schedule(
89- context = context,
90- token = key.token,
91- accountId = key.accountId,
92- )
93- }
125+ val currentFingerprint = token?.let { tokenFingerprint(it) }
94126
95- supervisorScope {
96- for (key in removed) {
97- PushRegistrationWorker .cancelRegistration(
98- context = context,
99- accountId = key.accountId,
100- )
101-
102- launch {
103- Log .d(TAG , " Unregistering push token for account: ${key.accountId} " )
104- try {
105- val swarmAuth = swarmAuthForAccount(key.accountId)
106- ? : throw IllegalStateException (" No SwarmAuth found for account: ${key.accountId} " )
107-
108- registry.unregister(
109- token = key.token,
110- swarmAuth = swarmAuth,
111- )
112-
113- Log .d(TAG , " Successfully unregistered push token for account: ${key.accountId} " )
114- } catch (e: Exception ) {
115- if (e !is CancellationException ) {
116- Log .e(TAG , " Failed to unregister push token for account: ${key.accountId} " , e)
117- }
118- }
119- }
120- }
121- }
127+ // Add missing (ensure periodic + run now) — only if we have a token.
128+ val accountsToAdd = activeAccounts - accountsAlreadyRegistered.keys
129+ if (accountsToAdd.isNotEmpty()) Log .d(TAG , " Adding ${accountsToAdd.size} PN registrations" )
130+ if (! token.isNullOrEmpty()) {
131+ accountsToAdd.forEach { id ->
132+ PushRegistrationWorker .ensurePeriodic(context, id, token, replace = false ) // KEEP
133+ PushRegistrationWorker .scheduleImmediate(context, id, token) // run now
134+ }
135+ }
136+
137+ // Token rotation: replace periodic where fingerprint mismatches.
138+ if (! token.isNullOrEmpty()) {
139+ var replaced = 0
140+ activeAccounts.forEach { id ->
141+ val tokenFingerprint = accountsAlreadyRegistered[id] ? : return @forEach
142+ if (tokenFingerprint != currentFingerprint) {
143+ PushRegistrationWorker .ensurePeriodic(context, id, token, replace = true ) // REPLACE
144+ PushRegistrationWorker .scheduleImmediate(context, id, token)
145+ replaced++
146+ }
147+ }
148+ if (replaced > 0 ) Log .d(TAG , " Replaced $replaced periodic PN workers due to token rotation" )
149+ }
150+
151+ // Removed subscriptions: cancel workers & attempt deregister.
152+ val accountToRemove = accountsAlreadyRegistered.keys - activeAccounts
153+ if (accountToRemove.isNotEmpty()) Log .d(TAG , " Removing ${accountToRemove.size} PN registrations" )
154+ supervisorScope {
155+ accountToRemove.forEach { id ->
156+ launch {
157+ PushRegistrationWorker .cancelAll(context, id)
158+ tryUnregister(token, id)
122159 }
160+ }
161+ }
162+ }
163+
164+ /* *
165+ * Build desired subscriptions: self (local number) + any group that shouldPoll.
166+ * */
167+ private fun desiredSubscriptions (): Set <AccountId > = buildSet {
168+ preferences.getLocalNumber()?.let { add(AccountId (it)) }
169+ val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
170+ groups.filter { it.shouldPoll }
171+ .mapTo(this ) { AccountId (it.groupAccountId) }
172+ }
173+
174+ private fun hasCoreIdentity (): Boolean {
175+ return preferences.getLocalNumber() != null && storage.getUserED25519KeyPair() != null
176+ }
177+
178+ /* *
179+ * Try to deregister if we still have credentials and a token to sign with.
180+ * Safe to no-op if token/auth missing (e.g., keys already deleted).
181+ */
182+ private suspend fun tryUnregister (token : String? , accountId : AccountId ) {
183+ if (token.isNullOrEmpty()) return
184+ val auth = swarmAuthForAccount(accountId) ? : return
185+ try {
186+ Log .d(TAG , " Unregistering PN for $accountId " )
187+ registry.unregister(token = token, swarmAuth = auth)
188+ Log .d(TAG , " Unregistered PN for $accountId " )
189+ } catch (e: Exception ) {
190+ if (e !is CancellationException ) {
191+ Log .e(TAG , " Unregister failed for $accountId " , e)
192+ } else {
193+ throw e
194+ }
123195 }
124196 }
125197
126198 private fun swarmAuthForAccount (accountId : AccountId ): SwarmAuth ? {
127199 return when (accountId.prefix) {
128200 IdPrefix .STANDARD -> storage.userAuth?.takeIf { it.accountId == accountId }
129- IdPrefix .GROUP -> configFactory.getGroupAuth(accountId)
130- else -> null // Unsupported account ID prefix
201+ IdPrefix .GROUP -> configFactory.getGroupAuth(accountId)
202+ else -> null
131203 }
132204 }
133205
134- private fun getGroupSubscriptions (
135- token : String
136- ): Set <SubscriptionKey > {
137- return configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
138- .asSequence()
139- .filter { it.shouldPoll }
140- .mapTo(hashSetOf()) { SubscriptionKey (accountId = AccountId (it.groupAccountId), token = token) }
206+ private fun parseAccountId (info : WorkInfo ): AccountId ? {
207+ val tag = info.tags.firstOrNull { it.startsWith(ARG_ACCOUNT_ID ) } ? : return null
208+ val hex = tag.removePrefix(ARG_ACCOUNT_ID )
209+ return AccountId .fromStringOrNull(hex)
141210 }
142211
143- private data class SubscriptionKey (val accountId : AccountId , val token : String )
144- }
212+ private fun parseTokenFingerprint (info : WorkInfo ): String? {
213+ val tag = info.tags.firstOrNull { it.startsWith(ARG_TOKEN ) } ? : return null
214+ return tag.removePrefix(ARG_TOKEN )
215+ }
216+
217+ companion object {
218+ private const val TAG = " PushRegistrationHandler"
219+
220+ const val TAG_PERIODIC = " pn-register-periodic"
221+ const val ARG_ACCOUNT_ID = " pn-account-"
222+ const val ARG_TOKEN = " pn-token-"
223+
224+ fun tokenFingerprint (token : String ): String {
225+ val digest = MessageDigest .getInstance(" SHA-256" )
226+ .digest(token.toByteArray(Charsets .UTF_8 ))
227+ val short = digest.copyOfRange(0 , 8 ) // 64 bits is plenty for equality checks
228+ @Suppress(" InlinedApi" )
229+ return android.util.Base64 .encodeToString(
230+ short,
231+ android.util.Base64 .NO_WRAP or android.util.Base64 .URL_SAFE
232+ )
233+ }
234+ }
235+ }
0 commit comments