From 2a3d59c9161eb0de61a057d983f36cef9b56ee4f Mon Sep 17 00:00:00 2001 From: Christian Kungler Date: Fri, 21 Aug 2026 22:01:54 -0700 Subject: [PATCH 1/4] fix(android): resolve channel push notification navigation failure --- app/lib/methods/canOpenRoom.ts | 84 ++++++++++++++++++------- app/sagas/__tests__/deepLinking.test.ts | 4 ++ app/sagas/deepLinking.js | 11 +++- app/sagas/state.js | 15 +++-- 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/app/lib/methods/canOpenRoom.ts b/app/lib/methods/canOpenRoom.ts index 8d9d7c5d95..fd282c331e 100644 --- a/app/lib/methods/canOpenRoom.ts +++ b/app/lib/methods/canOpenRoom.ts @@ -1,3 +1,5 @@ +import { Q } from '@nozbe/watermelondb'; + import { ERoomTypes } from '../../definitions'; import database from '../database'; import sdk from '../services/sdk'; @@ -72,36 +74,76 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name: } } +function formatRoom(room: any, rid?: string) { + return ( + room?.asPlain?.() ?? { + rid: rid ?? room.rid, + t: room.t, + name: room.name, + fname: room.fname, + prid: room.prid, + uids: room.uids, + usernames: room.usernames + } + ); +} + +async function findSubscriptionByRid(subsCollection: any, rid: string) { + try { + const room = await subsCollection.find(rid); + return formatRoom(room, rid); + } catch { + return null; + } +} + +async function findSubscriptionByName(subsCollection: any, name: string, roomType: string) { + try { + const rows = await subsCollection + .query(Q.or(Q.where('name', name), Q.where('rid', name)), Q.where('t', roomType), Q.take(1)) + .fetch(); + if (rows.length && rows[0]) { + return formatRoom(rows[0]); + } + } catch { + // Do nothing + } + return null; +} + export async function canOpenRoom({ rid, path }: { rid: string; path: string }): Promise { try { const db = database.active; - const subsCollection = db.get('subscriptions'); + const subsCollection = db?.get ? db.get('subscriptions') : null; + + if (subsCollection && rid) { + const room = await findSubscriptionByRid(subsCollection, rid); + if (room) { + return room; + } + } + + if (path) { + const [type, name] = path.split('/'); + const t = type as ERoomTypes; + const roomType = t === ERoomTypes.GROUP ? 'p' : t === ERoomTypes.DIRECT ? 'd' : (t as string) === 'channels' ? 'l' : 'c'; + + if (subsCollection && name) { + const room = await findSubscriptionByName(subsCollection, name, roomType); + if (room) { + return room; + } + } - if (rid) { try { - const room = await subsCollection.find(rid); - return { - rid, - t: room.t, - name: room.name, - fname: room.fname, - prid: room.prid, - uids: room.uids, - usernames: room.usernames - }; + const result = await open({ type: t, rid, name }); + return result; } catch (e) { - // Do nothing + return false; } } - const [type, name] = path.split('/'); - const t = type as ERoomTypes; - try { - const result = await open({ type: t, rid, name }); - return result; - } catch (e) { - return false; - } + return false; } catch (e) { return false; } diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 1e0dd9c424..8d1764f0b9 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -66,6 +66,10 @@ jest.mock('../../lib/services/voip/resetVoipState', () => ({ resetVoipState: jest.fn() })); +jest.mock('../../lib/services/socketHealth', () => ({ + recoverSocket: jest.fn(() => Promise.resolve('confirmed-alive')) +})); + jest.mock('../../lib/navigation/appNavigation', () => ({ __esModule: true, default: { diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 6da1e0bdcb..495ed08f0b 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -29,6 +29,7 @@ import { notifyUser } from '../lib/services/restApi'; import sdk from '../lib/services/sdk'; import Navigation, { waitForNavigationReady } from '../lib/navigation/appNavigation'; import { resetVoipState } from '../lib/services/voip/resetVoipState'; +import { recoverSocket } from '../lib/services/socketHealth'; const roomTypes = { channel: 'c', @@ -58,7 +59,15 @@ const navigate = function* navigate({ params }) { [type, name, , jumpToThreadId] = params.path.split('/'); } if (type !== 'invite' || params.rid) { - const room = yield canOpenRoom(params); + let room = yield canOpenRoom(params); + if (!room) { + try { + yield call(recoverSocket); + } catch (e) { + log(e); + } + room = yield canOpenRoom(params); + } if (room) { const item = { name, diff --git a/app/sagas/state.js b/app/sagas/state.js index 30bd3d4917..241db2b878 100644 --- a/app/sagas/state.js +++ b/app/sagas/state.js @@ -1,4 +1,4 @@ -import { select, takeLatest } from 'redux-saga/effects'; +import { call, select, takeLatest } from 'redux-saga/effects'; import log from '../lib/methods/helpers/log'; import { localAuthenticate, saveLastLocalAuthenticationSession } from '../lib/methods/helpers/localAuthentication'; @@ -29,12 +29,17 @@ const appHasComeBackToForeground = function* appHasComeBackToForeground() { const server = yield select(state => state.server.server); yield localAuthenticate(server); - recoverSocket().catch(e => log(e)); + try { + yield call(recoverSocket); + } catch (e) { + log(e); + } - // Check for pending notification when app comes to foreground (Android - notification tap while in background) - checkPendingNotification().catch(e => { + try { + yield call(checkPendingNotification); + } catch (e) { log('[state.js] Error checking pending notification:', e); - }); + } return yield setUserPresenceOnline(); } catch (e) { log(e); From 61972fdd8e4de99408c744e1663680929beb2563 Mon Sep 17 00:00:00 2001 From: Christian Kungler Date: Sat, 29 Aug 2026 10:59:38 -0700 Subject: [PATCH 2/4] test: add unit tests for local subscription resolution and socket recovery retry --- app/lib/methods/canOpenRoom.test.ts | 71 +++++++++++++++++++++++++ app/sagas/__tests__/deepLinking.test.ts | 31 +++++++++++ 2 files changed, 102 insertions(+) diff --git a/app/lib/methods/canOpenRoom.test.ts b/app/lib/methods/canOpenRoom.test.ts index 555212c844..417b47db36 100644 --- a/app/lib/methods/canOpenRoom.test.ts +++ b/app/lib/methods/canOpenRoom.test.ts @@ -2,6 +2,26 @@ import { canOpenRoom } from './canOpenRoom'; import sdk from '../services/sdk'; import { getRoomByTypeAndName } from '../services/restApi'; +const mockFind = jest.fn(); +const mockQuery = jest.fn(); + +jest.mock('../database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn((collection: string) => { + if (collection === 'subscriptions') { + return { + find: mockFind, + query: mockQuery + }; + } + return null; + }) + } + } +})); + jest.mock('../services/sdk', () => ({ __esModule: true, default: { @@ -18,6 +38,10 @@ const mockedGetRoomByTypeAndName = getRoomByTypeAndName as jest.Mock; beforeEach(() => { jest.clearAllMocks(); + mockFind.mockRejectedValue(new Error('not found')); + mockQuery.mockReturnValue({ + fetch: jest.fn().mockResolvedValue([]) + }); }); describe('canOpenRoom — GROUP deeplink', () => { @@ -138,6 +162,53 @@ describe('canOpenRoom — CHANNEL deeplink', () => { }); }); +describe('canOpenRoom — local subscription fast-path', () => { + const localSubFixture = { + rid: 'local-room-1', + t: 'c', + name: 'general', + fname: 'General Room', + prid: '', + uids: ['u1', 'u2'], + usernames: ['user1', 'user2'] + }; + + it('returns room immediately from local DB when rid matches without calling REST API', async () => { + mockFind.mockResolvedValueOnce(localSubFixture); + + const result = await canOpenRoom({ rid: 'local-room-1', path: '' }); + + expect(mockFind).toHaveBeenCalledWith('local-room-1'); + expect(mockedGetRoomByTypeAndName).not.toHaveBeenCalled(); + expect(mockedSdkPost).not.toHaveBeenCalled(); + expect(result).toEqual(localSubFixture); + }); + + it('returns room immediately from local DB when path matches channel name without calling REST API', async () => { + mockQuery.mockReturnValueOnce({ + fetch: jest.fn().mockResolvedValueOnce([localSubFixture]) + }); + + const result = await canOpenRoom({ rid: '', path: 'channel/general' }); + + expect(mockedGetRoomByTypeAndName).not.toHaveBeenCalled(); + expect(mockedSdkPost).not.toHaveBeenCalled(); + expect(result).toEqual(localSubFixture); + }); + + it('returns room from local DB when model has asPlain() method', async () => { + const modelWithAsPlain = { + ...localSubFixture, + asPlain: () => localSubFixture + }; + mockFind.mockResolvedValueOnce(modelWithAsPlain); + + const result = await canOpenRoom({ rid: 'local-room-1', path: '' }); + + expect(result).toEqual(localSubFixture); + }); +}); + describe('canOpenRoom — other paths', () => { it('returns false when no path and no rid', async () => { const result = await canOpenRoom({ rid: '', path: '' }); diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 8d1764f0b9..2536a0d82e 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -113,6 +113,7 @@ import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; import { waitForNavigationReady } from '../../lib/navigation/appNavigation'; import { loginOAuthOrSso } from '../../lib/services/connect'; +import { recoverSocket } from '../../lib/services/socketHealth'; import sdk from '../../lib/services/sdk'; import database from '../../lib/database'; import EventEmitter from '../../lib/methods/helpers/events'; @@ -240,6 +241,36 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + it('retries canOpenRoom after calling recoverSocket when the first canOpenRoom attempt fails', async () => { + const store = setupStore(); + const params = makeParamsWithToken(); + + // First call fails, second call succeeds after recoverSocket + jest + .mocked(canOpenRoom) + .mockResolvedValueOnce(false as any) + .mockResolvedValueOnce({ rid: 'room-1', name: 'general', t: 'c' } as any); + + store.dispatch(deepLinkingOpen(params)); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(recoverSocket)).toHaveBeenCalled(); + expect(jest.mocked(canOpenRoom)).toHaveBeenCalledTimes(2); + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. From 592bb331dee5ae5cde254aba667e593432d03795 Mon Sep 17 00:00:00 2001 From: Christian Kungler Date: Sat, 29 Aug 2026 11:14:44 -0700 Subject: [PATCH 3/4] fix(deeplinking): preserve rid fallback and add type annotations in canOpenRoom --- app/lib/methods/canOpenRoom.test.ts | 7 ++++ app/lib/methods/canOpenRoom.ts | 54 +++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/app/lib/methods/canOpenRoom.test.ts b/app/lib/methods/canOpenRoom.test.ts index 417b47db36..060b17391a 100644 --- a/app/lib/methods/canOpenRoom.test.ts +++ b/app/lib/methods/canOpenRoom.test.ts @@ -210,6 +210,13 @@ describe('canOpenRoom — local subscription fast-path', () => { }); describe('canOpenRoom — other paths', () => { + it('returns { rid } fallback when rid is provided but path is empty and not found locally', async () => { + mockFind.mockRejectedValueOnce(new Error('not found')); + + const result = await canOpenRoom({ rid: 'remote-rid-123', path: '' }); + expect(result).toEqual({ rid: 'remote-rid-123' }); + }); + it('returns false when no path and no rid', async () => { const result = await canOpenRoom({ rid: '', path: '' }); expect(result).toBe(false); diff --git a/app/lib/methods/canOpenRoom.ts b/app/lib/methods/canOpenRoom.ts index fd282c331e..bcbcabd2f0 100644 --- a/app/lib/methods/canOpenRoom.ts +++ b/app/lib/methods/canOpenRoom.ts @@ -1,11 +1,23 @@ +import type { Collection } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb'; -import { ERoomTypes } from '../../definitions'; +import { ERoomTypes, type ISubscription, type TSubscriptionModel } from '../../definitions'; import database from '../database'; import sdk from '../services/sdk'; import { createDirectMessage } from './createDirectMessage'; import { getRoomByTypeAndName } from '../services/restApi'; +export interface ICanOpenRoomResult { + rid: string; + t?: string; + name?: string; + fname?: string; + prid?: string; + uids?: string[]; + usernames?: string[]; + [key: string]: any; +} + async function openGroup(roomId: string) { try { // RC 0.61.0 @@ -74,7 +86,10 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name: } } -function formatRoom(room: any, rid?: string) { +/** + * Formats a subscription model or raw subscription object into a plain room representation. + */ +function formatRoom(room: TSubscriptionModel | ISubscription | any, rid?: string): ICanOpenRoomResult | ISubscription { return ( room?.asPlain?.() ?? { rid: rid ?? room.rid, @@ -88,7 +103,13 @@ function formatRoom(room: any, rid?: string) { ); } -async function findSubscriptionByRid(subsCollection: any, rid: string) { +/** + * Queries local WatermelonDB subscriptions collection for a room by ID. + */ +async function findSubscriptionByRid( + subsCollection: Collection, + rid: string +): Promise { try { const room = await subsCollection.find(rid); return formatRoom(room, rid); @@ -97,7 +118,14 @@ async function findSubscriptionByRid(subsCollection: any, rid: string) { } } -async function findSubscriptionByName(subsCollection: any, name: string, roomType: string) { +/** + * Queries local WatermelonDB subscriptions collection for a room by name or room ID. + */ +async function findSubscriptionByName( + subsCollection: Collection, + name: string, + roomType: string +): Promise { try { const rows = await subsCollection .query(Q.or(Q.where('name', name), Q.where('rid', name)), Q.where('t', roomType), Q.take(1)) @@ -111,10 +139,20 @@ async function findSubscriptionByName(subsCollection: any, name: string, roomTyp return null; } -export async function canOpenRoom({ rid, path }: { rid: string; path: string }): Promise { +/** + * Determines whether a room can be opened from a deep link or push notification payload, + * resolving from local database first and falling back to remote REST API calls. + */ +export async function canOpenRoom({ + rid, + path +}: { + rid: string; + path: string; +}): Promise { try { const db = database.active; - const subsCollection = db?.get ? db.get('subscriptions') : null; + const subsCollection = (db?.get ? db.get('subscriptions') : null) as Collection | null; if (subsCollection && rid) { const room = await findSubscriptionByRid(subsCollection, rid); @@ -143,6 +181,10 @@ export async function canOpenRoom({ rid, path }: { rid: string; path: string }): } } + if (rid) { + return { rid }; + } + return false; } catch (e) { return false; From 4f86d27fd3a3dd75413dd4f54a802b6782edf9aa Mon Sep 17 00:00:00 2001 From: Christian Kungler Date: Sat, 29 Aug 2026 14:04:27 -0700 Subject: [PATCH 4/4] docs: clarify JSDoc comments with design rationale --- app/lib/methods/canOpenRoom.ts | 17 ++++++++++++----- app/sagas/__tests__/deepLinking.test.ts | 4 +++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/lib/methods/canOpenRoom.ts b/app/lib/methods/canOpenRoom.ts index bcbcabd2f0..965fd520a3 100644 --- a/app/lib/methods/canOpenRoom.ts +++ b/app/lib/methods/canOpenRoom.ts @@ -87,7 +87,9 @@ async function open({ type, rid, name }: { type: ERoomTypes; rid: string; name: } /** - * Formats a subscription model or raw subscription object into a plain room representation. + * WatermelonDB models are lazy proxies that can't be passed across the saga/navigation + * boundary. Normalize to a plain object so downstream consumers (goRoom, navigate) receive + * serializable data. */ function formatRoom(room: TSubscriptionModel | ISubscription | any, rid?: string): ICanOpenRoomResult | ISubscription { return ( @@ -104,7 +106,9 @@ function formatRoom(room: TSubscriptionModel | ISubscription | any, rid?: string } /** - * Queries local WatermelonDB subscriptions collection for a room by ID. + * Push notifications carry the room ID. Resolving by rid first avoids a network round trip + * and succeeds even when the socket is still reconnecting after a background-to-foreground + * transition. */ async function findSubscriptionByRid( subsCollection: Collection, @@ -119,7 +123,9 @@ async function findSubscriptionByRid( } /** - * Queries local WatermelonDB subscriptions collection for a room by name or room ID. + * Deep-link path segments may hold either a room name or a room ID depending on how the + * link was generated. Querying by both with a room-type filter avoids a false-negative + * when the path contains an ID instead of a name. */ async function findSubscriptionByName( subsCollection: Collection, @@ -140,8 +146,9 @@ async function findSubscriptionByName( } /** - * Determines whether a room can be opened from a deep link or push notification payload, - * resolving from local database first and falling back to remote REST API calls. + * Local resolution is attempted before the REST fallback so that notification taps succeed + * instantly even when the network or DDP socket is temporarily unavailable (e.g. Android + * transitioning from a doze/background state). */ export async function canOpenRoom({ rid, diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 2536a0d82e..1c2689fe27 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -245,7 +245,9 @@ describe('deepLinking saga — Regression race (new server + token + room path)' const store = setupStore(); const params = makeParamsWithToken(); - // First call fails, second call succeeds after recoverSocket + // Simulate a dormant socket: the first canOpenRoom fails because the REST + // fallback can't reach the server; after recoverSocket restores the connection, + // the retry succeeds. jest .mocked(canOpenRoom) .mockResolvedValueOnce(false as any)