fix: preserve social graph and notifications across account switches

The social graph (ExtendedNetworkRepository + SocialGraphDb) was wiped
on every account switch because clear() deleted both the SharedPreferences
cache and SQLite tables, and SocialGraphDb used a single shared DB file.
Now SocialGraphDb uses per-account files with a one-time legacy migration,
and clear() no longer touches persistent state (only in-memory). Notification
events from account A were leaking into account B because the ObjectBox
seeding coroutine fired after the switch and NotificationRepository had no
reload(). Adds reload() to NotificationRepository/SafetyPreferences
with a stale-pubkey rejection guard and proper job cancellation.
This commit is contained in:
Barry Deen
2026-04-19 12:42:29 -04:00
parent 3e27713078
commit 7d5b988005
6 changed files with 134 additions and 43 deletions
@@ -511,15 +511,14 @@ class ExtendedNetworkRepository(
_cachedNetwork.value = null
_discoveryState.value = DiscoveryState.Idle
pendingFollowLists.clear()
socialGraphDb.clearAll()
discoveryTotal = 0
prefs.edit().clear().apply()
}
fun reload(pubkeyHex: String?) {
clear()
this.pubkeyHex = pubkeyHex
prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE)
socialGraphDb.reload(pubkeyHex)
loadFromPrefs()
}
@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
class NotificationRepository(
context: Context,
private val context: Context,
pubkeyHex: String?,
private val muteRepo: MuteRepository? = null,
private val eventRepo: EventRepository? = null
@@ -31,7 +31,9 @@ class NotificationRepository(
var contactRepo: ContactRepository? = null
var extendedNetworkRepo: com.wisp.app.repo.ExtendedNetworkRepository? = null
private val prefs: SharedPreferences =
@Volatile private var currentPubkeyHex: String? = pubkeyHex
private var prefs: SharedPreferences =
context.getSharedPreferences("wisp_notif_${pubkeyHex ?: "anon"}", Context.MODE_PRIVATE)
private val seenEvents = LruCache<String, Boolean>(2000)
@@ -71,8 +73,8 @@ class NotificationRepository(
@Volatile var isViewing: Boolean = false
private var lastReadTimestamp: Long = prefs.getLong(KEY_LAST_READ, 0L)
private var latestNotifTs: Long = prefs.getLong(KEY_LATEST_NOTIF_TS, 0L)
@Volatile private var lastReadTimestamp: Long = prefs.getLong(KEY_LAST_READ, 0L)
@Volatile private var latestNotifTs: Long = prefs.getLong(KEY_LATEST_NOTIF_TS, 0L)
private val _replyReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val replyReceived: SharedFlow<Unit> = _replyReceived
@@ -136,6 +138,17 @@ class NotificationRepository(
}
fun addEvent(event: NostrEvent, myPubkey: String, replyToMyEvent: Boolean = false, source: String = "") {
// Reject events whose target pubkey does not match the active account.
// Catches stale in-flight coroutines (e.g. ObjectBox seeding) that captured
// a pubkey from a previous account and are still running after a switch.
val currentOwner = currentPubkeyHex
if (currentOwner != null && myPubkey != currentOwner) {
if (DiagnosticLogger.isEnabled) {
DiagnosticLogger.log("NOTIF", "REJECTED:stale_pubkey id=${event.id.take(12)} " +
"kind=${event.kind} myPubkey=${myPubkey.take(8)} currentOwner=${currentOwner.take(8)} source=$source")
}
return
}
if (event.pubkey == myPubkey) return
// Defense-in-depth: reject events from blocked users even if the caller forgot to check.
// For zap receipts, event.pubkey is the lightning service — check the actual zapper too.
@@ -290,8 +303,23 @@ class NotificationRepository(
_hasUnread.value = false
soundEligibleAfter = System.currentTimeMillis() / 1000
}
latestNotifTs = 0L
prefs.edit().clear().apply()
// DO NOT reset latestNotifTs or wipe prefs here — `prefs` may still
// point to the outgoing account during switch. `reload()` re-points
// prefs and re-reads these timestamps from the correct file.
}
/** Re-keys the repository to a new account: wipes in-memory state and
* re-points `prefs` to the new account's file. */
fun reload(newPubkeyHex: String?) {
clear()
synchronized(lock) {
currentPubkeyHex = newPubkeyHex
prefs = context.getSharedPreferences("wisp_notif_${newPubkeyHex ?: "anon"}", Context.MODE_PRIVATE)
@Suppress("ktlint:standard:property-naming")
lastReadTimestamp = prefs.getLong("last_read_timestamp", 0L)
@Suppress("ktlint:standard:property-naming")
latestNotifTs = prefs.getLong("latest_notif_ts", 0L)
}
}
fun purgeUser(pubkey: String) = synchronized(lock) {
@@ -5,8 +5,8 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class SafetyPreferences(context: Context, pubkeyHex: String? = null) {
private val prefs: SharedPreferences =
class SafetyPreferences(private val context: Context, pubkeyHex: String? = null) {
private var prefs: SharedPreferences =
context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE)
private val _spamFilterEnabled = MutableStateFlow(prefs.getBoolean(KEY_SPAM_FILTER, true))
@@ -15,7 +15,8 @@ class SafetyPreferences(context: Context, pubkeyHex: String? = null) {
private val _wotFilterEnabled = MutableStateFlow(prefs.getBoolean(KEY_WOT_FILTER, false))
val wotFilterEnabled: StateFlow<Boolean> = _wotFilterEnabled
private val safelistSet = HashSet(prefs.getStringSet(KEY_SPAM_SAFELIST, emptySet()) ?: emptySet())
private var safelistSet =
HashSet(prefs.getStringSet(KEY_SPAM_SAFELIST, emptySet()) ?: emptySet())
private val _spamSafelist = MutableStateFlow<Set<String>>(safelistSet.toSet())
val spamSafelist: StateFlow<Set<String>> = _spamSafelist
@@ -43,6 +44,15 @@ class SafetyPreferences(context: Context, pubkeyHex: String? = null) {
prefs.edit().putStringSet(KEY_SPAM_SAFELIST, safelistSet.toSet()).apply()
}
/** Re-point to the new account's prefs file and refresh all StateFlows. */
fun reload(newPubkeyHex: String?) {
prefs = context.getSharedPreferences(prefsName(newPubkeyHex), Context.MODE_PRIVATE)
_spamFilterEnabled.value = prefs.getBoolean(KEY_SPAM_FILTER, true)
_wotFilterEnabled.value = prefs.getBoolean(KEY_WOT_FILTER, false)
safelistSet = HashSet(prefs.getStringSet(KEY_SPAM_SAFELIST, emptySet()) ?: emptySet())
_spamSafelist.value = safelistSet.toSet()
}
companion object {
private const val KEY_SPAM_FILTER = "spam_filter_enabled"
private const val KEY_WOT_FILTER = "wot_filter_enabled"
@@ -6,31 +6,78 @@ import android.database.sqlite.SQLiteOpenHelper
import android.database.sqlite.SQLiteStatement
import android.util.Log
class SocialGraphDb(context: Context) : SQLiteOpenHelper(context, "social_graph.db", null, 1) {
class SocialGraphDb(private val context: Context, initialPubkeyHex: String? = null) {
companion object {
private const val TAG = "SocialGraphDb"
private const val LEGACY_DB_NAME = "social_graph.db"
private fun dbName(pubkeyHex: String?): String =
if (pubkeyHex != null) "social_graph_$pubkeyHex.db" else LEGACY_DB_NAME
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE followed_by (
pubkey TEXT NOT NULL,
follower TEXT NOT NULL,
PRIMARY KEY (pubkey, follower)
)
""".trimIndent())
db.execSQL("CREATE INDEX idx_followed_by_pubkey ON followed_by(pubkey)")
private class InternalHelper(ctx: Context, dbName: String) :
SQLiteOpenHelper(ctx, dbName, null, 1) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE followed_by (
pubkey TEXT NOT NULL,
follower TEXT NOT NULL,
PRIMARY KEY (pubkey, follower)
)
""".trimIndent())
db.execSQL("CREATE INDEX idx_followed_by_pubkey ON followed_by(pubkey)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
@Volatile private var helper: InternalHelper =
migrateAndOpen(initialPubkeyHex)
/**
* One-time migration: if the legacy `social_graph.db` exists AND the target
* per-account file does not yet exist, rename legacy -> per-account so that
* existing single-account users retain their computed graph after upgrade.
* If no pubkey is provided OR the target already exists, the helper is
* opened against the target filename as-is (no migration).
*/
private fun migrateAndOpen(pubkeyHex: String?): InternalHelper {
if (pubkeyHex != null) {
val legacyFile = context.getDatabasePath(LEGACY_DB_NAME)
val targetFile = context.getDatabasePath(dbName(pubkeyHex))
if (legacyFile.exists() && !targetFile.exists()) {
try {
targetFile.parentFile?.mkdirs()
val renamed = legacyFile.renameTo(targetFile)
// Also move the -journal and -wal/-shm sidecars if present.
for (suffix in listOf("-journal", "-wal", "-shm")) {
val srcSide = context.getDatabasePath(LEGACY_DB_NAME + suffix)
val dstSide = context.getDatabasePath(dbName(pubkeyHex) + suffix)
if (srcSide.exists() && !dstSide.exists()) srcSide.renameTo(dstSide)
}
Log.i(TAG, "Migrated legacy social_graph.db -> ${dbName(pubkeyHex)} (renamed=$renamed)")
} catch (e: Exception) {
Log.e(TAG, "Legacy social graph migration failed; using empty per-account DB", e)
}
}
}
return InternalHelper(context, dbName(pubkeyHex))
}
/** Swap to a different account's DB file. Closes the current helper. */
@Synchronized
fun reload(newPubkeyHex: String?) {
try { helper.close() } catch (_: Exception) {}
helper = migrateAndOpen(newPubkeyHex)
}
fun insertBatch(rows: List<Pair<String, String>>) {
if (rows.isEmpty()) return
val db = writableDatabase
val db = helper.writableDatabase
db.beginTransaction()
try {
val stmt: SQLiteStatement = db.compileStatement("INSERT OR IGNORE INTO followed_by (pubkey, follower) VALUES (?, ?)")
val stmt: SQLiteStatement =
db.compileStatement("INSERT OR IGNORE INTO followed_by (pubkey, follower) VALUES (?, ?)")
for ((pubkey, follower) in rows) {
stmt.bindString(1, pubkey)
stmt.bindString(2, follower)
@@ -45,41 +92,38 @@ class SocialGraphDb(context: Context) : SQLiteOpenHelper(context, "social_graph.
fun getFollowers(pubkey: String): List<String> {
val result = mutableListOf<String>()
val db = readableDatabase
db.rawQuery("SELECT follower FROM followed_by WHERE pubkey = ?", arrayOf(pubkey)).use { cursor ->
while (cursor.moveToNext()) {
result.add(cursor.getString(0))
}
helper.readableDatabase.rawQuery(
"SELECT follower FROM followed_by WHERE pubkey = ?", arrayOf(pubkey)
).use { cursor ->
while (cursor.moveToNext()) result.add(cursor.getString(0))
}
return result
}
fun getFollowerCount(pubkey: String): Int {
val db = readableDatabase
db.rawQuery("SELECT COUNT(*) FROM followed_by WHERE pubkey = ?", arrayOf(pubkey)).use { cursor ->
return if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
helper.readableDatabase.rawQuery(
"SELECT COUNT(*) FROM followed_by WHERE pubkey = ?", arrayOf(pubkey)
).use { cursor -> return if (cursor.moveToFirst()) cursor.getInt(0) else 0 }
}
fun getTopByFollowerCount(limit: Int, fromPubkeys: Set<String>): List<Pair<String, Int>> {
if (fromPubkeys.isEmpty()) return emptyList()
val result = mutableListOf<Pair<String, Int>>()
val db = readableDatabase
val placeholders = fromPubkeys.joinToString(",") { "?" }
db.rawQuery(
helper.readableDatabase.rawQuery(
"SELECT pubkey, COUNT(*) as cnt FROM followed_by WHERE follower IN ($placeholders) GROUP BY pubkey ORDER BY cnt DESC LIMIT ?",
fromPubkeys.toTypedArray() + limit.toString()
).use { cursor ->
while (cursor.moveToNext()) {
result.add(cursor.getString(0) to cursor.getInt(1))
}
while (cursor.moveToNext()) result.add(cursor.getString(0) to cursor.getInt(1))
}
return result
}
/** Explicit wipe of the current DB's contents. NOT called during account
* switch — only invoked by a future explicit "recompute from scratch" path. */
fun clearAll() {
try {
writableDatabase.execSQL("DELETE FROM followed_by")
helper.writableDatabase.execSQL("DELETE FROM followed_by")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear social graph", e)
}
@@ -229,7 +229,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
onGroupReconnect?.invoke()
}
)
val socialGraphDb = SocialGraphDb(app)
val socialGraphDb = SocialGraphDb(app, pubkeyHex)
val extendedNetworkRepo = ExtendedNetworkRepository(
app, contactRepo, muteRepo, relayListRepo, relayPool, subManager, relayScoreBoard, pubkeyHex, socialGraphDb
)
@@ -432,6 +432,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
liveStreamRepo.clear()
}
fun reloadForNewAccount() {
safetyPrefs.reload(getUserPubkey())
startup.reloadForNewAccount()
groupRepo.reload(getUserPubkey())
}
@@ -115,6 +115,7 @@ class StartupCoordinator(
private var notifRefreshJob: Job? = null
private var startupJob: Job? = null
private var healthSnapshotJob: Job? = null
private var notifSeedJob: Job? = null
var relaysInitialized = false
private set
@@ -130,6 +131,7 @@ class StartupCoordinator(
notifRefreshJob?.cancel()
startupJob?.cancel()
healthSnapshotJob?.cancel()
notifSeedJob?.cancel()
feedSub.reset()
// Stop lifecycle manager and disconnect relays
@@ -169,7 +171,7 @@ class StartupCoordinator(
val newPubkey = getUserPubkey()
// Clear stale data from previous account and re-key to new pubkey
notifRepo.clear()
notifRepo.reload(newPubkey)
eventRepo.clearAll()
if (newPubkey != null) dmRepo.reload(newPubkey) else dmRepo.clear()
@@ -639,7 +641,8 @@ class StartupCoordinator(
// immediately without waiting for relay responses. addEvent handles all
// p-tag / ownership filtering, so we can pass events through unfiltered.
eventPersistence?.let { persistence ->
scope.launch(processingContext) {
notifSeedJob?.cancel()
notifSeedJob = scope.launch(processingContext) {
val cached = persistence.getRecentNotificationEvents(limit = 500)
.filter { event ->
// Only seed events that reference the current user via p-tag.
@@ -648,7 +651,13 @@ class StartupCoordinator(
// kind 6 p-tag bypass in addEvent.
event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == myPubkey }
}
for (event in cached) notifRepo.addEvent(event, myPubkey)
for (event in cached) {
if (getUserPubkey() != myPubkey) {
Log.d("StartupCoord", "Notif seeding aborted: pubkey changed mid-seed")
return@launch
}
notifRepo.addEvent(event, myPubkey)
}
Log.d("StartupCoord", "Seeded notifRepo with ${cached.size} cached events")
}
}