Skip to content

Commit fc3e6b9

Browse files
Merge pull request #1596 from session-foundation/feature/ses-4638-pn-re-register
SES-4638 pn re-registration
2 parents cd84660 + 2264286 commit fc3e6b9

File tree

7 files changed

+270
-137
lines changed

7 files changed

+270
-137
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ dependencies {
402402
implementation(libs.phrase)
403403
implementation(libs.copper.flow)
404404
implementation(libs.kotlinx.coroutines.android)
405+
implementation(libs.kotlinx.coroutines.guava)
405406
implementation(libs.kovenant)
406407
implementation(libs.kovenant.android)
407408
implementation(libs.opencsv)

app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,6 @@ class RemoteFileDownloadWorker @AssistedInject constructor(
288288
return File(downloadsDirectory(context), remote.sha256Hash())
289289
}
290290

291-
fun cancelAll(context: Context) {
292-
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
293-
}
294-
295291
private fun uniqueWorkName(remote: RemoteFile): String {
296292
return "download-remote-file-${remote.sha256Hash()}"
297293
}
Lines changed: 163 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package org.thoughtcrime.securesms.notifications
22

33
import android.content.Context
4+
import androidx.work.WorkInfo
5+
import androidx.work.WorkManager
46
import dagger.hilt.android.qualifiers.ApplicationContext
57
import kotlinx.coroutines.CancellationException
68
import kotlinx.coroutines.CoroutineScope
@@ -10,8 +12,9 @@ import kotlinx.coroutines.Job
1012
import kotlinx.coroutines.flow.Flow
1113
import kotlinx.coroutines.flow.combine
1214
import kotlinx.coroutines.flow.debounce
15+
import kotlinx.coroutines.flow.distinctUntilChanged
1316
import kotlinx.coroutines.flow.onStart
14-
import kotlinx.coroutines.flow.scan
17+
import kotlinx.coroutines.guava.await
1518
import kotlinx.coroutines.launch
1619
import kotlinx.coroutines.supervisorScope
1720
import org.session.libsession.database.userAuth
@@ -25,28 +28,28 @@ import org.thoughtcrime.securesms.database.Storage
2528
import org.thoughtcrime.securesms.dependencies.ConfigFactory
2629
import org.thoughtcrime.securesms.dependencies.ManagerScope
2730
import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent
31+
import java.security.MessageDigest
2832
import javax.inject.Inject
2933
import 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

Comments
 (0)