Skip to content

Commit 94f4482

Browse files
authored
push notification changes (#29058)
* switch accounts on push * deduplicate silent notifications * lock * cleanup * swift fmt * add username to plaintext notifications push title (#29049) * add username to plaintext notifications push title * condition on multiple logged in accounts * don't cache errors * x * x
1 parent c5f812a commit 94f4482

17 files changed

Lines changed: 189 additions & 21 deletions

File tree

go/bind/keybase.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func log(format string, args ...interface{}) {
8686
}
8787

8888
type PushNotifier interface {
89-
LocalNotification(ident string, msg string, badgeCount int, soundName string, convID string, typ string)
89+
LocalNotification(ident string, title string, msg string, badgeCount int, soundName string, convID string, typ string)
9090
DisplayChatNotification(notification *ChatNotification)
9191
}
9292

@@ -304,6 +304,7 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string,
304304
kbCtx = libkb.NewGlobalContext()
305305
kbCtx.Init()
306306
kbCtx.SetProofServices(externals.NewProofServices(kbCtx))
307+
kbCtx.AddLogoutHook(accountCacheLogoutHook{}, "notifications/accountCache")
307308

308309
var suffix string
309310
if isIPad {
@@ -730,7 +731,7 @@ func pushPendingMessageFailure(obrs []chat1.OutboxRecord, pusher PushNotifier) {
730731
for _, obr := range obrs {
731732
if topicType := obr.Msg.ClientHeader.Conv.TopicType; obr.Msg.IsBadgableType() && topicType == chat1.TopicType_CHAT {
732733
kbCtx.Log.Debug("pushPendingMessageFailure: pushing convID: %s", obr.ConvID)
733-
pusher.LocalNotification("failedpending",
734+
pusher.LocalNotification("failedpending", "",
734735
"Heads up! Your message hasn't sent yet, tap here to retry.",
735736
-1, "default", obr.ConvID.String(), "chat.failedpending")
736737
return

go/bind/notifications.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"fmt"
77
"regexp"
88
"runtime"
9+
"strconv"
10+
"sync"
911
"time"
1012

13+
lru "github.com/hashicorp/golang-lru"
1114
"github.com/keybase/client/go/chat"
1215
"github.com/keybase/client/go/chat/globals"
1316
"github.com/keybase/client/go/chat/storage"
@@ -20,6 +23,42 @@ import (
2023
"github.com/kyokomi/emoji"
2124
)
2225

26+
var (
27+
seenNotificationsMtx sync.Mutex
28+
seenNotifications, _ = lru.New(100)
29+
30+
multipleAccountsMtx sync.Mutex
31+
multipleAccountsCached *bool
32+
)
33+
34+
// accountCacheLogoutHook implements libkb.LogoutHook. It clears the cached
35+
// result of hasMultipleLoggedInAccounts so that the next background
36+
// notification recomputes it against the post-logout account list.
37+
type accountCacheLogoutHook struct{}
38+
39+
func (accountCacheLogoutHook) OnLogout(_ libkb.MetaContext) error {
40+
multipleAccountsMtx.Lock()
41+
multipleAccountsCached = nil
42+
multipleAccountsMtx.Unlock()
43+
return nil
44+
}
45+
46+
func hasMultipleLoggedInAccounts(ctx context.Context) bool {
47+
multipleAccountsMtx.Lock()
48+
defer multipleAccountsMtx.Unlock()
49+
if multipleAccountsCached != nil {
50+
return *multipleAccountsCached
51+
}
52+
users, err := kbCtx.GetUsersWithStoredSecrets(ctx)
53+
if err != nil {
54+
// Don't cache on error; retry next time.
55+
return false
56+
}
57+
result := len(users) > 1
58+
multipleAccountsCached = &result
59+
return result
60+
}
61+
2362
type Person struct {
2463
KeybaseUsername string
2564
KeybaseAvatar string
@@ -47,6 +86,8 @@ type ChatNotification struct {
4786
IsPlaintext bool
4887
SoundName string
4988
BadgeCount int
89+
// Title is the notification title, e.g. "username@keybase"
90+
Title string
5091
}
5192

5293
func HandlePostTextReply(strConvID, tlfName string, intMessageID int, body string) (err error) {
@@ -106,6 +147,23 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
106147
return libkb.LoginRequiredError{}
107148
}
108149
mp := chat.NewMobilePush(gc)
150+
// Dedupe by convID||msgID
151+
dupKey := strConvID + "||" + strconv.Itoa(intMessageID)
152+
// Optimistic early-exit: check under the mutex so that if another goroutine
153+
// is currently in the display+add critical section below, we wait for it to
154+
// finish and then see the cache entry rather than proceeding with redundant work.
155+
seenNotificationsMtx.Lock()
156+
_, isDup := seenNotifications.Get(dupKey)
157+
seenNotificationsMtx.Unlock()
158+
if isDup {
159+
// Cancel any duplicate visible notifications
160+
if len(pushID) > 0 {
161+
mp.AckNotificationSuccess(ctx, []string{pushID})
162+
}
163+
kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID)
164+
// Return nil (not an error) so Android does not treat this as failure and show a fallback notification.
165+
return nil
166+
}
109167
uid := gregor1.UID(kbCtx.Env.GetUID().ToBytes())
110168
convID, err := chat1.MakeConvID(strConvID)
111169
if err != nil {
@@ -119,6 +177,11 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
119177
return err
120178
}
121179

180+
currentUsername := string(kbCtx.Env.GetUsername())
181+
title := "Keybase"
182+
if hasMultipleLoggedInAccounts(ctx) {
183+
title = fmt.Sprintf("%s@keybase", currentUsername)
184+
}
122185
chatNotification := ChatNotification{
123186
IsPlaintext: displayPlaintext,
124187
Message: &Message{
@@ -131,10 +194,12 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
131194
TopicName: conv.Info.TopicName,
132195
TlfName: conv.Info.TlfName,
133196
IsGroupConversation: len(conv.Info.Participants) > 2,
134-
ConversationName: utils.FormatConversationName(conv.Info, string(kbCtx.Env.GetUsername())),
197+
ConversationName: utils.FormatConversationName(conv.Info, currentUsername),
135198
SoundName: soundName,
136199
BadgeCount: badgeCount,
200+
Title: title,
137201
}
202+
kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: title=%s", chatNotification.Title)
138203

139204
msgUnboxed, err := mp.UnboxPushNotification(ctx, uid, convID, membersType, body)
140205
if err == nil && msgUnboxed.IsValid() {
@@ -195,6 +260,22 @@ func HandleBackgroundNotification(strConvID, body, serverMessageBody, sender str
195260

196261
// only display and ack this notification if we actually have something to display
197262
if pusher != nil && (len(chatNotification.Message.Plaintext) > 0 || len(chatNotification.Message.ServerMessage) > 0) {
263+
// Lock and check if we've already processed this notification.
264+
seenNotificationsMtx.Lock()
265+
defer seenNotificationsMtx.Unlock()
266+
if _, ok := seenNotifications.Get(dupKey); ok {
267+
// Cancel any duplicate visible notifications
268+
if len(pushID) > 0 {
269+
mp.AckNotificationSuccess(ctx, []string{pushID})
270+
}
271+
kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID)
272+
// Return nil (not an error) so Android does not treat this as failure and show a fallback notification.
273+
return nil
274+
}
275+
// Add to cache before displaying so that any concurrent goroutine that
276+
// reaches the second check while DisplayChatNotification is running will
277+
// see the entry and bail out rather than displaying a duplicate.
278+
seenNotifications.Add(dupKey, struct{}{})
198279
pusher.DisplayChatNotification(&chatNotification)
199280
if len(pushID) > 0 {
200281
mp.AckNotificationSuccess(ctx, []string{pushID})

go/libkb/secret_store.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,22 @@ func GetConfiguredAccountsFromProvisionedUsernames(m MetaContext, s SecretStoreA
118118
allUsernames = append(allUsernames, currentUsername)
119119
}
120120

121+
// Build UIDs first so we can attach them to each account
122+
uids := make([]keybase1.UID, len(allUsernames))
123+
for idx, username := range allUsernames {
124+
uids[idx] = GetUIDByNormalizedUsername(m.G(), username)
125+
}
126+
121127
accounts := make(map[NormalizedUsername]keybase1.ConfiguredAccount)
122-
for _, username := range allUsernames {
128+
for idx, username := range allUsernames {
123129
accounts[username] = keybase1.ConfiguredAccount{
124130
Username: username.String(),
125131
IsCurrent: username.Eq(currentUsername),
132+
Uid: uids[idx],
126133
}
127134
}
128135

129136
// Get the full names
130-
uids := make([]keybase1.UID, len(allUsernames))
131-
for idx, username := range allUsernames {
132-
uids[idx] = GetUIDByNormalizedUsername(m.G(), username)
133-
}
134137
usernamePackages, err := m.G().UIDMapper.MapUIDsToUsernamePackages(m.Ctx(), m.G(),
135138
uids, time.Hour*24, time.Second*10, false)
136139
if err != nil {

go/protocol/keybase1/login.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

protocol/avdl/keybase1/login.avdl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ protocol login {
99
FullName fullname;
1010
boolean hasStoredSecret;
1111
boolean isCurrent;
12+
UID uid;
1213
}
1314

1415
/**

protocol/json/keybase1/login.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ class KBPushNotifier internal constructor(private val context: Context, private
122122
val convData = ConvData(chatNotification.convID, chatNotification.tlfName ?: "", chatNotification.message.id)
123123
val builder = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID)
124124
.setSmallIcon(R.drawable.ic_notif)
125+
.setContentTitle(chatNotification.title ?: "")
125126
.setContentIntent(pending_intent)
126127
.setAutoCancel(true)
127128
var notificationDefaults = NotificationCompat.DEFAULT_LIGHTS or NotificationCompat.DEFAULT_VIBRATE
@@ -228,9 +229,9 @@ class KBPushNotifier internal constructor(private val context: Context, private
228229
notificationManager.notify(uniqueTag, 0, builder.build())
229230
}
230231

231-
override fun localNotification(ident: String, msg: String, badgeCount: Long, soundName: String, convID: String,
232+
override fun localNotification(ident: String, title: String, msg: String, badgeCount: Long, soundName: String, convID: String,
232233
typ: String) {
233-
genericNotification(ident, "", msg, bundle, KeybasePushNotificationListenerService.GENERAL_CHANNEL_ID)
234+
genericNotification(ident, title, msg, bundle, KeybasePushNotificationListenerService.GENERAL_CHANNEL_ID)
234235
}
235236

236237
companion object {

shared/constants/daemon/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,14 @@ export const useDaemonState = Z.createZustand<State>((set, get) => {
226226
const usernameToFullname: {[username: string]: string} = {}
227227

228228
configuredAccounts.forEach(account => {
229-
const {username, isCurrent, fullname, hasStoredSecret} = account
229+
const {username, isCurrent, fullname, hasStoredSecret, uid} = account
230230
if (username === defaultUsername) {
231231
existingDefaultFound = true
232232
}
233233
if (isCurrent) {
234234
currentName = account.username
235235
}
236-
nextConfiguredAccounts.push({hasStoredSecret, username})
236+
nextConfiguredAccounts.push({hasStoredSecret, uid, username})
237237
usernameToFullname[username] = fullname
238238
})
239239
if (!existingDefaultFound) {

shared/constants/platform-specific/push.native.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type DataNewMessageSilent2 = DataCommon & {
3434
}
3535
type DataFollow = DataCommon & {
3636
type: 'follow'
37+
targetUID?: string
3738
username?: string
3839
}
3940
type DataChatExtension = DataCommon & {
@@ -70,6 +71,8 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => {
7071

7172
const data = _n as PushN
7273
const userInteraction = !!data.userInteraction
74+
const dataUid = data as {uid?: string; targetUID?: string}
75+
const forUid = dataUid.uid
7376

7477
switch (data.type) {
7578
case 'chat.readmessage': {
@@ -83,6 +86,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => {
8386
return data.convID
8487
? {
8588
conversationIDKey: T.Chat.stringToConversationIDKey(data.convID),
89+
forUid,
8690
membersType: anyToConversationMembersType(data.t),
8791
type: 'chat.newmessage',
8892
unboxPayload: data.m || '',
@@ -105,6 +109,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => {
105109
case 'follow':
106110
return data.username
107111
? {
112+
forUid: forUid ?? dataUid.targetUID,
108113
type: 'follow',
109114
userInteraction,
110115
username: data.username,
@@ -114,6 +119,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => {
114119
return data.convID
115120
? {
116121
conversationIDKey: T.Chat.stringToConversationIDKey(data.convID),
122+
forUid,
117123
type: 'chat.extension',
118124
}
119125
: undefined
@@ -199,6 +205,31 @@ export const initPushListener = () => {
199205

200206
storeRegistry.getState('push').dispatch.initialPermissionsCheck()
201207

208+
// Second half of the account-switch-for-notification flow: when the uid
209+
// changes (i.e. login completes after a switch initiated in handlePush in
210+
// constants/push.native.tsx), replay the pending notification for the new
211+
// account if it was addressed to them.
212+
storeRegistry.getStore('current-user').subscribe((s, old) => {
213+
if (s.uid === old.uid) return
214+
const pushState = storeRegistry.getState('push')
215+
const pending = pushState.pendingPushNotification
216+
if (!pending) return
217+
const forUid = (pending as {forUid?: string}).forUid
218+
if (!forUid || forUid !== s.uid) return
219+
pushState.dispatch.clearPendingPushNotification()
220+
pushState.dispatch.handlePush(pending)
221+
})
222+
223+
// Clear pending push on logout, but not during an account switch — the switch
224+
// flow sets userSwitching=true before triggering logout, and the pending
225+
// notification must survive until the new account finishes bootstrapping.
226+
storeRegistry.getStore('config').subscribe((s, old) => {
227+
if (s.loggedIn === old.loggedIn) return
228+
if (!s.loggedIn && !s.userSwitching) {
229+
storeRegistry.getState('push').dispatch.clearPendingPushNotification()
230+
}
231+
})
232+
202233
const listenNative = async () => {
203234
const RNEmitter = getNativeEmitter()
204235

shared/constants/push.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import type {UseBoundStore, StoreApi} from 'zustand'
44
type Store = T.Immutable<{
55
hasPermissions: boolean
66
justSignedUp: boolean
7+
pendingPushNotification?: T.Push.PushNotification
78
showPushPrompt: boolean
89
token: string
910
}>
1011

1112
export type State = Store & {
1213
dispatch: {
1314
checkPermissions: () => Promise<boolean>
15+
clearPendingPushNotification: () => void
1416
deleteToken: (version: number) => void
1517
handlePush: (notification: T.Push.PushNotification) => void
1618
initialPermissionsCheck: () => void

0 commit comments

Comments
 (0)