From 02178fb611e2d3877c4aa12b733e844bbec62b6d Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:25:56 +0000 Subject: [PATCH] android, desktop: fix accepting contact request from notification for non-active profile (#7316) * android, desktop: switch to the request's profile when accepting contact request from notification * plans: justify accepting contact request from notification fix --- .../simplex/common/platform/NtfManager.kt | 18 +++- ...7-fix-accept-request-non-active-profile.md | 98 +++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 plans/2026-07-27-fix-accept-request-non-active-profile.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 385120f18b..89f23f3326 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -49,11 +49,21 @@ abstract class NtfManager { } fun acceptContactRequestAction(userId: Long?, incognito: Boolean, chatId: ChatId) { - val isCurrentUser = ChatModel.currentUser.value?.userId == userId val apiId = chatId.replace("<@", "").toLongOrNull() ?: return - // TODO include remote host in notification - acceptContactRequest(null, incognito, apiId, isCurrentUser, ChatModel) - cancelNotificationsForChat(chatId) + withLongRunningApi { + awaitChatStartedIfNeeded(chatModel) + // switching to the user the request was sent to, so that accepted contact is shown + if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { + chatModel.controller.showProgressIfNeeded { + chatModel.controller.changeActiveUser(null, userId, null) + } + chatModel.clearOverlays.value = true + } + val isCurrentUser = chatModel.currentUser.value?.userId == userId + // TODO include remote host in notification + acceptContactRequest(null, incognito, apiId, isCurrentUser, chatModel) + cancelNotificationsForChat(chatId) + } } fun openChatAction(userId: Long?, chatId: ChatId) { diff --git a/plans/2026-07-27-fix-accept-request-non-active-profile.md b/plans/2026-07-27-fix-accept-request-non-active-profile.md new file mode 100644 index 0000000000..43b28d6563 --- /dev/null +++ b/plans/2026-07-27-fix-accept-request-non-active-profile.md @@ -0,0 +1,98 @@ +# Accepting a contact request from a notification for a non-active profile + +## Problem + +With two profiles on one device, a contact request that arrives for the profile that is **not** +currently active shows a notification, and tapping **Accept** in it fails with: + +``` +ERROR accepting contact request: error store: error store userContactLinkNotFound +``` + +Repro: create an address in profile 1, create profile 2, connect to profile 2's address from +profile 1, then accept the resulting request from the notification while profile 1 is active. + +## Cause + +`APIAcceptContact` is scoped to the **active** user (`Library/Commands.hs`): + +```haskell +APIAcceptContact incognito connReqId -> withUser $ \user@User {userId} -> do + uclData_ <- withFastStore $ \db -> do + uclId_ <- getUserContactLinkIdByCReq db connReqId -- NOT user-scoped + forM uclId_ $ \uclId -> do + uclGLinkInfo <- getUserContactLinkById db userId uclId -- user-scoped -> throws +``` + +`getUserContactLinkIdByCReq` (`Store/Direct.hs`) has no `user_id` filter, so it returns the address +id of the *other* profile; `getUserContactLinkById` (`Store/Profiles.hs`) then filters on +`user_id = ?` and throws `SEUserContactLinkNotFound`. Every chat-preview/chat-item query is +unaffected — only the accept fails. + +The client never compensated: `NtfManager.acceptContactRequestAction` computed `isCurrentUser` +only to decide whether to update the chat model, and called the API without switching profile — +unlike its neighbours `openChatAction` and `showChatsAction`, which both call `changeActiveUser`. +iOS is not affected: `processNotificationResponse` has switched the active user since +`06a0dbd0f` (2023). + +The core-side scoping is itself a regression. `7dd4dc3b4` ("core: support accepting contact +requests for non active users (for accepting via notification)", #1809) deliberately made this +command use the request's own user via `getContactRequest'`. `7f6bc3089` (#5978, first released in +v6.4.0-beta.4) reverted it to `withUser $ \user@User {userId}` + user-scoped `getContactRequest` +as a side effect of unrelated short-link work, leaving `getUserByContactRequestId` +(`Store/Direct.hs`) as dead code. So the bug predates the v7 line. + +## Fix + +Client-side, in `NtfManager.acceptContactRequestAction`: switch to the profile the request was +sent to before calling the API, mirroring `openChatAction`/`showChatsAction` and iOS. + +- `changeActiveUser` is called only when the target profile differs from the active one; the + accept then runs in the right profile, and `isCurrentUser` — computed *after* the switch — is + true, so the accepted contact is inserted into the chat list the user is now looking at instead + of being silently dropped. +- The body moves into `withLongRunningApi` and gains `awaitChatStartedIfNeeded`, which the two + sibling actions already had. This is required, not incidental: `APISetActiveUser` starts with + `unlessM (lift chatStarted) $ throwChatError CEChatNotStarted`, so without the wait a tap during + cold start would fail the switch, and the switch is the whole fix. +- `clearOverlays` is set when a switch happened, so a modal left open by the previous profile does + not end up rendering the new profile's data. It is scoped to the switch branch on purpose: the + siblings clear unconditionally because they *navigate*, which accepting does not. + +## Alternative considered and rejected + +Restoring the core behaviour — deriving the user from the request via the already-present +`getUserByContactRequestId` instead of `withUser` — was implemented, built, and covered by a test +(`accept contact request for non active user`, passing), then dropped. It fixes the API for all +callers (terminal `/_accept`, bots, the python/nodejs SDKs) and does not depend on a client-side +switch that swallows its own errors. It was rejected for this fix because the UI has to switch +profiles anyway for the result to be visible, so the core capability would never be exercised by +the app, and the client change alone resolves every path reachable from a notification. The core +API therefore remains active-user-scoped, and `getUserByContactRequestId` remains unused. + +## Known gaps not addressed here + +- `acceptContactRequestAction` passes `rhId = null` (its own long-standing TODO), so accepting from + a notification always targets the local core. A request that arrived on a *remote host* produces + a notification carrying a remote user id, and the new `changeActiveUser(null, userId, null)` will + switch the **local** profile. `openChatAction` — the notification's default tap action — already + has this flaw, so this extends an existing pattern rather than introducing one; the real fix is + carrying the remote host id in the notification. +- On desktop the Accept action is not clickable: `NtfManager.desktop.kt` passes the action to + two-slices, whose Linux backend does not render action buttons, and it passes + `NotificationAction.ACCEPT_CONTACT_REQUEST.name` as the label instead of + `generalGetString(MR.strings.accept)`. The bug is therefore Android-only in practice. +- Accepting from a notification still does not open the new contact's chat, whereas iOS dismisses + sheets and calls `loadOpenChat` from inside `acceptContactRequest` when `contact.sndReady`. The + Kotlin equivalent is the existing `close` callback of `acceptContactRequest`, which the + notification path passes as `null`. +- `APIRejectContact` is also active-user-scoped and fails the same way. It is left alone: unlike + accept, it never supported non-active users (#1809 changed only accept), and the notification has + no Reject action. + +## Testing + +- `:common:compileKotlinDesktop` and `:android:assembleDebug` build clean. +- Manual, Android: profile 1 active, request lands on inactive profile 2, tap Accept in the + notification — the app switches to profile 2, the contact appears in the list, no error. +- No automated coverage: the changed path is reachable only from a notification action.