Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go/bind/keybase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 32 additions & 6 deletions go/bind/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -129,7 +139,7 @@ var spoileRegexp = regexp.MustCompile(`!>(.*?)<!`)

func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender string, intMembersType int,
displayPlaintext bool, intMessageID int, pushID string, badgeCount, unixTime int, soundName string,
pusher PushNotifier, showIfStale bool,
pusher PushNotifier, showIfStale bool, targetUID string,
) (err error) {
if err := waitForInit(10 * time.Second); err != nil {
return err
Expand All @@ -146,6 +156,22 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
if !kbCtx.ActiveDevice.HaveKeys() {
return libkb.LoginRequiredError{}
}
// If the push includes a target UID, verify it matches the currently active account
// before doing any work or updating any state (including badge count).
if targetUID != "" {
activeUID := string(kbCtx.Env.GetUID())
if activeUID != targetUID {
kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: push targetUID %s != active uid %s, ignoring", targetUID, activeUID)
// On Android, return an error so the caller can suppress badge updates for
// silent pushes and fall back to a visible notification for loud pushes.
// On iOS the system-delivered alert remains available, so returning nil
// avoids noisy background-processing failures.
if runtime.GOOS == "android" {
return errAndroidNotificationForOtherAccount
}
return nil
}
}
mp := chat.NewMobilePush(gc)
// Dedupe by convID||msgID
dupKey := strConvID + "||" + strconv.Itoa(intMessageID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.core.graphics.drawable.IconCompat
import io.keybase.ossifrage.MainActivity
import keybase.ChatNotification
import keybase.PushNotifier
import me.leolin.shortcutbadger.ShortcutBadger
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
Expand Down Expand Up @@ -172,6 +173,11 @@ class KBPushNotifier internal constructor(private val context: Context, private
}
val notification = builder.build()
notificationManager.notify(chatNotification.convID, 0, notification)
// Apply badge count now that Go has confirmed this notification is for the
// active account (targetUID check passed in HandleBackgroundNotification).
if (chatNotification.badgeCount >= 0) {
ShortcutBadger.applyCount(context, chatNotification.badgeCount.toInt())
}
} catch (e: Exception) {
io.keybase.ossifrage.modules.NativeLogger.error("KBPushNotifier.displayChatNotification2 exception: " + e.message)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,10 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() {

// Avoid ever showing doubles
private val seenChatNotifications = HashSet<String>()
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]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -106,30 +110,55 @@ 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 {
override fun task() {
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) {
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions shared/constants/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,16 @@ export const useConfigState = Z.createZustand<State>((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) {
Expand All @@ -1041,9 +1050,10 @@ export const useConfigState = Z.createZustand<State>((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
Expand Down
15 changes: 15 additions & 0 deletions shared/constants/platform-specific/push.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions shared/constants/push.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type State = Store & {
dispatch: {
checkPermissions: () => Promise<boolean>
clearPendingPushNotification: () => void
setPendingPushNotification: (notification: T.Push.PushNotification) => void
deleteToken: (version: number) => void
handlePush: (notification: T.Push.PushNotification) => void
initialPermissionsCheck: () => void
Expand Down
1 change: 1 addition & 0 deletions shared/constants/push.desktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const usePushState = Z.createZustand<State>(() => {
rejectPermissions: () => {},
requestPermissions: () => {},
resetState: 'default',
setPendingPushNotification: () => {},
setPushToken: () => {},
showPermissionsPrompt: () => {},
}
Expand Down
22 changes: 21 additions & 1 deletion shared/constants/push.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,25 @@ export const usePushState = Z.createZustand<State>((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) {
Expand Down Expand Up @@ -295,6 +310,11 @@ export const usePushState = Z.createZustand<State>((set, get) => {
ignorePromise(f())
},
resetState: 'default',
setPendingPushNotification: (notification: T.Push.PushNotification) => {
set(s => {
s.pendingPushNotification = notification
})
},
setPushToken: (token: string) => {
set(s => {
s.token = token
Expand Down
3 changes: 2 additions & 1 deletion shared/ios/Keybase/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down