diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 9bca94baf085..95a70e9fef0d 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -304,7 +304,8 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string, kbCtx = libkb.NewGlobalContext() kbCtx.Init() kbCtx.SetProofServices(externals.NewProofServices(kbCtx)) - kbCtx.AddLogoutHook(accountCacheLogoutHook{}, "notifications/accountCache") + kbCtx.AddLoginHook(accountCacheHook{}) + kbCtx.AddLogoutHook(accountCacheHook{}, "notifications/accountCache") var suffix string if isIPad { diff --git a/go/bind/notifications.go b/go/bind/notifications.go index c4788a9ca82e..c04fc25cd7a7 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -31,15 +31,25 @@ var ( multipleAccountsCached *bool ) -// accountCacheLogoutHook implements libkb.LogoutHook. It clears the cached -// result of hasMultipleLoggedInAccounts so that the next background -// notification recomputes it against the post-logout account list. -type accountCacheLogoutHook struct{} +var errAndroidNotificationForOtherAccount = errors.New("android notification for different account") -func (accountCacheLogoutHook) OnLogout(_ libkb.MetaContext) error { +func clearMultipleAccountsCache() { multipleAccountsMtx.Lock() multipleAccountsCached = nil multipleAccountsMtx.Unlock() +} + +// accountCacheHook clears the cached result of hasMultipleLoggedInAccounts +// whenever login/logout changes the available stored-secret accounts. +type accountCacheHook struct{} + +func (accountCacheHook) OnLogin(_ libkb.MetaContext) error { + clearMultipleAccountsCache() + return nil +} + +func (accountCacheHook) OnLogout(_ libkb.MetaContext) error { + clearMultipleAccountsCache() return nil } @@ -129,7 +139,7 @@ var spoileRegexp = regexp.MustCompile(`!>(.*?)= 0) { + ShortcutBadger.applyCount(context, chatNotification.badgeCount.toInt()) + } } catch (e: Exception) { io.keybase.ossifrage.modules.NativeLogger.error("KBPushNotifier.displayChatNotification2 exception: " + e.message) } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index 7b7146a92953..0b483f01682b 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt @@ -17,8 +17,8 @@ import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase import keybase.ChatNotification -import me.leolin.shortcutbadger.ShortcutBadger import com.reactnativekb.KbModule +import me.leolin.shortcutbadger.ShortcutBadger import org.json.JSONArray import org.json.JSONObject import android.util.Log @@ -30,6 +30,10 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { // Avoid ever showing doubles private val seenChatNotifications = HashSet() + private fun isOtherAccountPushError(ex: Exception): Boolean { + return ex.message?.contains("different account") == true + } + private fun buildStyle(convID: String, person: Person): NotificationCompat.Style { val style = NotificationCompat.MessagingStyle(person) val buf = msgCache[convID] @@ -67,10 +71,10 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { if (!bundle.containsKey("color")) { bundle.putString("color", data.optString("color", "")) } - val badge = data.optInt("badge", -1) - if (badge >= 0) { - ShortcutBadger.applyCount(this, badge) - } + // Do not apply badge here: the data.badge value comes from the server + // for an unspecified account and may not match the currently active user. + // Badge updates are applied in displayChatNotification2 (after Go validates + // the target UID) or via the Gregor badge-state RPC path. } try { val type = bundle.getString("type") @@ -106,6 +110,15 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { notifier.setMsgCache(msgCache[n.convID]) + // Both push types include a target UID so Go can reject the notification + // early when the push is addressed to a different logged-in account: + // chat.newmessage → "uid" field + // chat.newmessageSilent_2 → "i" field (added server-side) + val targetUID = when (type) { + "chat.newmessage" -> bundle.getString("uid", "") + else -> bundle.getString("i", "") + } + var goProcessingSucceeded = false try { val withBackgroundActive: WithBackgroundActive = object : WithBackgroundActive { @@ -113,23 +126,39 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { try { Keybase.handleBackgroundNotification(n.convID, payload, n.serverMessageBody, n.sender, n.membersType.toLong(), n.displayPlaintext, n.messageId.toLong(), n.pushId, - n.badgeCount.toLong(), n.unixTime, n.soundName, if (dontNotify) null else notifier, true) + n.badgeCount.toLong(), n.unixTime, n.soundName, if (dontNotify) null else notifier, true, + targetUID) goProcessingSucceeded = true if (!dontNotify) { seenChatNotifications.add(n.convID + n.messageId) } } catch (ex: Exception) { - NativeLogger.error("Go Couldn't handle background notification2: " + ex.message) + if (isOtherAccountPushError(ex)) { + NativeLogger.info("Go skipped notification for a different active account: " + ex.message) + } else { + NativeLogger.error("Go Couldn't handle background notification2: " + ex.message) + } throw ex } } } withBackgroundActive.whileActive(applicationContext) } catch (ex: Exception) { - NativeLogger.error("Failed to process notification (app may not be running): " + ex.message) + if (isOtherAccountPushError(ex)) { + NativeLogger.info("Skipping active-account processing for different-account push") + } else { + NativeLogger.error("Failed to process notification (app may not be running): " + ex.message) + } goProcessingSucceeded = false } + // For silent pushes the notifier is null so DisplayChatNotification is + // never called and KBPushNotifier cannot apply the badge. Apply it here + // after Go has validated the target UID, mirroring Pusher.swift on iOS. + if (goProcessingSucceeded && dontNotify && n.badgeCount >= 0) { + ShortcutBadger.applyCount(applicationContext, n.badgeCount) + } + val isReactNativeRunning = try { com.reactnativekb.KbModule.isReactNativeRunning() } catch (e: Exception) { @@ -170,10 +199,12 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { chatNotif.message = message chatNotif.isPlaintext = n.displayPlaintext + chatNotif.badgeCount = -1 chatNotif.soundName = n.soundName ?: "default" chatNotif.conversationName = "" chatNotif.isGroupConversation = false chatNotif.tlfName = "" + chatNotif.title = bundle.getString("title", "") notifier.displayChatNotification(chatNotif) seenChatNotifications.add(n.convID + n.messageId) diff --git a/shared/constants/config/index.tsx b/shared/constants/config/index.tsx index f4bd996927fa..13663fe9cbd3 100644 --- a/shared/constants/config/index.tsx +++ b/shared/constants/config/index.tsx @@ -1026,7 +1026,16 @@ export const useConfigState = Z.createZustand((set, get) => { ignorePromise(f()) } } else { + // During an account switch the pending push notification must survive the + // store reset so that it can be replayed once the new account's uid is set. + const {userSwitching} = get() + const pendingPush = userSwitching + ? storeRegistry.getState('push').pendingPushNotification + : undefined Z.resetAllStores() + if (pendingPush) { + storeRegistry.getState('push').dispatch.setPendingPushNotification(pendingPush) + } } if (loggedIn) { @@ -1041,9 +1050,10 @@ export const useConfigState = Z.createZustand((set, get) => { set(s => { s.loginError = error }) - // On login error, turn off the user switching flag, so that the login screen is not - // hidden and the user can see and respond to the error. + // On login error, turn off the user switching flag so the login screen is not hidden, + // and clear any pending push notification — the switch failed so there's nothing to replay. get().dispatch.setUserSwitching(false) + storeRegistry.getState('push').dispatch.clearPendingPushNotification() }, setMobileAppState: nextAppState => { if (get().mobileAppState === nextAppState) return diff --git a/shared/constants/platform-specific/push.native.tsx b/shared/constants/platform-specific/push.native.tsx index cc2f7724ac86..6a953c78b767 100644 --- a/shared/constants/platform-specific/push.native.tsx +++ b/shared/constants/platform-specific/push.native.tsx @@ -220,6 +220,21 @@ export const initPushListener = () => { pushState.dispatch.handlePush(pending) }) + // If a tapped notification arrives before configuredAccounts has loaded, keep + // it pending and retry once the account list is available. + storeRegistry.getStore('config').subscribe((s, old) => { + if (s.configuredAccounts === old.configuredAccounts || s.userSwitching) return + const pushState = storeRegistry.getState('push') + const pending = pushState.pendingPushNotification + if (!pending) return + const forUid = (pending as {forUid?: string}).forUid + if (!forUid || forUid === storeRegistry.getState('current-user').uid) return + const account = s.configuredAccounts.find(acc => acc.uid === forUid) + if (!account?.hasStoredSecret) return + pushState.dispatch.clearPendingPushNotification() + pushState.dispatch.handlePush(pending) + }) + // Clear pending push on logout, but not during an account switch — the switch // flow sets userSwitching=true before triggering logout, and the pending // notification must survive until the new account finishes bootstrapping. diff --git a/shared/constants/push.d.ts b/shared/constants/push.d.ts index c0d151a5b59b..68491222a500 100644 --- a/shared/constants/push.d.ts +++ b/shared/constants/push.d.ts @@ -13,6 +13,7 @@ export type State = Store & { dispatch: { checkPermissions: () => Promise clearPendingPushNotification: () => void + setPendingPushNotification: (notification: T.Push.PushNotification) => void deleteToken: (version: number) => void handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void diff --git a/shared/constants/push.desktop.tsx b/shared/constants/push.desktop.tsx index 7d4de613e78e..4a34741dbd6e 100644 --- a/shared/constants/push.desktop.tsx +++ b/shared/constants/push.desktop.tsx @@ -22,6 +22,7 @@ export const usePushState = Z.createZustand(() => { rejectPermissions: () => {}, requestPermissions: () => {}, resetState: 'default', + setPendingPushNotification: () => {}, setPushToken: () => {}, showPermissionsPrompt: () => {}, } diff --git a/shared/constants/push.native.tsx b/shared/constants/push.native.tsx index fccd7a284c0f..3feb7ede9afb 100644 --- a/shared/constants/push.native.tsx +++ b/shared/constants/push.native.tsx @@ -152,10 +152,25 @@ export const usePushState = Z.createZustand((set, get) => { if (forUid) { const currentUid = storeRegistry.getState('current-user').uid if (forUid !== currentUid) { + // Only switch accounts if the user explicitly tapped the notification. + // Background/silent deliveries (userInteraction=false) must not trigger + // a switch — that's what was causing spurious account switches when + // foregrounding the app or receiving background pushes. + const userInteraction = + 'userInteraction' in notification + ? (notification as {userInteraction?: boolean}).userInteraction + : false + if (!userInteraction) { + logger.info('[Push] notification for different account but no userInteraction, skipping') + return + } const {configuredAccounts, dispatch: configDispatch} = storeRegistry.getState('config') const account = configuredAccounts.find(acc => acc.uid === forUid) if (!account) { - logger.info('[Push] notification forUid not in configured accounts, skipping') + logger.info('[Push] notification forUid not in configured accounts yet, waiting to retry') + set(s => { + s.pendingPushNotification = notification + }) return } if (!account.hasStoredSecret) { @@ -295,6 +310,11 @@ export const usePushState = Z.createZustand((set, get) => { ignorePromise(f()) }, resetState: 'default', + setPendingPushNotification: (notification: T.Push.PushNotification) => { + set(s => { + s.pendingPushNotification = notification + }) + }, setPushToken: (token: string) => { set(s => { s.token = token diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 54c04ce57293..914be6d79866 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -361,12 +361,13 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, let displayPlaintext = (notification["n"] as? NSNumber)?.boolValue ?? false let membersType = (notification["t"] as? NSNumber)?.intValue ?? 0 let sender = notification["u"] as? String + let targetUID = notification["i"] as? String ?? "" let pusher = PushNotifier() var err: NSError? Keybasego.KeybaseHandleBackgroundNotification( convID, body, "", sender, membersType, displayPlaintext, messageID, pushID, badgeCount, - unixTime, soundName, pusher, false, &err) + unixTime, soundName, pusher, false, targetUID, &err) if let err { NSLog("Failed to handle in engine: \(err)") } completionHandler(.newData) NSLog("Remote notification handle finished...")