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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user