diff --git a/src/screens/application/container/applicationContainer.tsx b/src/screens/application/container/applicationContainer.tsx index 2ae6701685..3bdd254cf8 100644 --- a/src/screens/application/container/applicationContainer.tsx +++ b/src/screens/application/container/applicationContainer.tsx @@ -90,6 +90,16 @@ import { decryptKey, encryptKey } from '../../../utils/crypto'; import parseVersionNumber from '../../../utils/parseVersionNumber'; import { setMomentLocale } from '../../../utils/time'; import { purgeExpiredCache } from '../../../redux/actions/cacheActions'; +import { + PUSH_REGISTRATION_DEDUPE_MS, + PUSH_REGISTRATION_REFRESH_MS, + beginPushRegistration, + decryptAccessToken, + disablePushRegistrations, + getPushSystem, + getSignedInAccounts, +} from '../../../utils/pushRegistration'; +import { captureException, captureMessage } from '../../../utils/sentryUtils'; import { fetchSubscribedCommunities } from '../../../redux/actions/communitiesAction'; import MigrationHelpers, { repairOtherAccountsData, @@ -118,6 +128,7 @@ import { } from '../../../redux/selectors'; let firebaseOnMessageListener: any = null; +let firebaseTokenRefreshListener: (() => void) | null = null; let appStateSub: NativeEventSubscription | null = null; class ApplicationContainer extends Component { @@ -135,6 +146,18 @@ class ApplicationContainer extends Component { _fcmAvailable: boolean | null = null; // Cache FCM availability check + _lastPushRegistration = 0; + + // Accounts whose last push registration the server refused (401/403). + _rejectedPushRegistrations = new Set(); + + // Accounts being logged out: still listed until the logout finishes, but never + // registered again. + _departingAccounts = new Set(); + + // Push registration problems already reported to Sentry this session. + _reportedPushProblems = new Set(); + constructor(props: any) { super(props); this.state = { @@ -159,6 +182,7 @@ class ApplicationContainer extends Component { } else { console.log('FCM not available - will use WebSocket fallback when user logs in'); } + this._createTokenRefreshListener(); // set avatar cache stamp to invalidate previous session avatars dispatch(setAvatarCacheStamp(new Date().getTime())); @@ -197,7 +221,21 @@ class ApplicationContainer extends Component { }; componentDidUpdate(prevProps: any) { - const { isGlobalRenderRequired, dispatch } = this.props; + const { isGlobalRenderRequired, dispatch, currentAccount } = this.props; + + // A login (by any method) or an account switch makes a different account current. + // Register it right away instead of waiting for the next cold start. The access + // token is renewed on every start and foreground, so a token change alone only + // retries an account whose last registration the server refused. + const accountChanged = + !!currentAccount?.name && currentAccount.name !== prevProps.currentAccount?.name; + const rejectedTokenRenewed = + !!currentAccount?.name && + currentAccount.local?.accessToken !== prevProps.currentAccount?.local?.accessToken && + this._rejectedPushRegistrations.has(currentAccount.name); + if (accountChanged || rejectedTokenRenewed) { + this._registerAccountForNotifications(currentAccount); + } if (isGlobalRenderRequired !== prevProps.isGlobalRenderRequired && isGlobalRenderRequired) { this.setState( @@ -230,6 +268,11 @@ class ApplicationContainer extends Component { firebaseOnMessageListener(); } + if (firebaseTokenRefreshListener) { + firebaseTokenRefreshListener(); + firebaseTokenRefreshListener = null; + } + this._disconnectNotificationServer(); this.netListener(); @@ -326,6 +369,9 @@ class ApplicationContainer extends Component { this._refreshGlobalProps(); this._refreshUnreadActivityCount(); this._refreshUnreadChats(); + if (Date.now() - this._lastPushRegistration > PUSH_REGISTRATION_REFRESH_MS) { + this._registerDeviceForNotifications(); + } // Refresh account data to get latest profile updates from blockchain if (currentAccount?.local) { this._fetchUserDataFromDsteem(currentAccount.local); @@ -358,7 +404,10 @@ class ApplicationContainer extends Component { await this._getUserDataFromRealm(); await this._refreshUnreadChats(); this._compareAndPromptForUpdate(); - this._registerDeviceForNotifications(); + // Reconnects run this twice in a row (NetInfo listener and props change). + if (Date.now() - this._lastPushRegistration > PUSH_REGISTRATION_DEDUPE_MS) { + this._registerDeviceForNotifications(); + } dispatch(purgeExpiredCache()); }; @@ -403,6 +452,21 @@ class ApplicationContainer extends Component { }); }; + // FCM rotates tokens (app data restore, reinstall over backup, periodic refresh). + // The old token stops working, so register the new one for every account now. + _createTokenRefreshListener = () => { + if (firebaseTokenRefreshListener) { + return; + } + try { + firebaseTokenRefreshListener = getMessaging().onTokenRefresh(() => { + this._registerDeviceForNotifications(); + }); + } catch (error) { + console.warn('Failed to listen for push token refresh', error); + } + }; + _handleConntectionChange = (status: any) => { const { dispatch, isConnected } = this.props; @@ -717,45 +781,65 @@ class ApplicationContainer extends Component { } }; - // update notification settings and update push token for each signed accoutn useing access tokens - _registerDeviceForNotifications = (settings?: any) => { - const { currentAccount, otherAccounts, notificationDetails, isNotificationsEnabled } = - this.props; - - const isEnabled = settings ? !!settings.notification : isNotificationsEnabled; - settings = settings || notificationDetails; - - const _enabledNotificationForAccount = (account: any) => { - const encAccessToken = account?.local?.accessToken; - // otherAccounts entries are keyed by username; name can be undefined on some - // (e.g. HiveSigner) entries, so fall back to username. - this._enableNotification( - account.name || account.username, - isEnabled, - settings, - encAccessToken, - ); - }; + // Register the push token with each signed-in account's notification settings. + _registerDeviceForNotifications = () => { + const { currentAccount, otherAccounts } = this.props; - // updateing fcm token with settings; - otherAccounts.forEach((account: any) => { - // since there can be more than one accounts, process access tokens separate - if (account?.local?.accessToken) { - _enabledNotificationForAccount(account); - return; - } + this._lastPushRegistration = Date.now(); - // No stored access token on this other-account entry. This is common and benign - // (HiveSigner accounts, or entries keyed only by username), so do NOT report it to - // Sentry - it previously fired an error on every launch (ECENCY-MOBILE-1QY). - const acctName = account?.name || account?.username; - if (acctName && currentAccount?.name === acctName) { - // fallback to current account access token to register at least the logged-in account - _enabledNotificationForAccount(currentAccount); + // Accounts without a stored access token (HiveSigner accounts, entries keyed only by + // username) are skipped without a report: reporting them fired an error on every + // launch (ECENCY-MOBILE-1QY). + getSignedInAccounts(currentAccount, otherAccounts).forEach(({ username, account }) => { + if (!this._departingAccounts.has(username)) { + this._registerAccountForNotifications(account); } }); }; + _signedInPushAccount = (username: string) => { + if (this._departingAccounts.has(username)) { + return undefined; + } + const { currentAccount, otherAccounts } = this.props; + return getSignedInAccounts(currentAccount, otherAccounts).find( + (signedIn) => signedIn.username === username, + )?.account; + }; + + _registerAgainIfSignedIn = (username: string) => { + const account = this._signedInPushAccount(username); + if (account) { + this._registerAccountForNotifications(account); + } + }; + + // Reports a push registration problem once per session. + _reportPushProblem = (key: string, report: () => void) => { + if (!this._reportedPushProblems.has(key)) { + this._reportedPushProblems.add(key); + report(); + } + }; + + _registerAccountForNotifications = (account: any) => { + const { notificationDetails, isNotificationsEnabled } = this.props; + const encAccessToken = account?.local?.accessToken; + + if (!encAccessToken) { + return; + } + + // otherAccounts entries are keyed by username; name can be undefined on some + // (e.g. HiveSigner) entries, so fall back to username. + this._enableNotification( + account.name || account.username, + isNotificationsEnabled, + notificationDetails, + encAccessToken, + ); + }; + /** * Check if FCM (Firebase Cloud Messaging) is available on this device * Returns cached result if already checked @@ -1052,7 +1136,17 @@ class ApplicationContainer extends Component { }; _logout = async (username: any) => { - const { currentAccount, otherAccounts, dispatch, intl } = this.props; + const { currentAccount, otherAccounts, dispatch, intl, pinCode } = this.props; + + // Read the token before the account data is removed below. + const loggedOutAccount = + currentAccount?.name === username + ? currentAccount + : otherAccounts.find((user: any) => (user.username || user.name) === username); + const accessToken = decryptAccessToken(loggedOutAccount?.local?.accessToken, pinCode); + + // Until the logout finishes the account is still listed; keep it out of registrations. + this._departingAccounts.add(username); try { const response = await removeUserData(username); @@ -1062,13 +1156,17 @@ class ApplicationContainer extends Component { } this._updatePrevLoggedInUsersList(username); - const encAccessToken = - currentAccount.name === username ? currentAccount?.local?.accessToken : null; - this._enableNotification(username, false, null, encAccessToken); - // switch account if other account exist const _otherAccounts = otherAccounts.filter((user: any) => user.username !== username); + // Stop pushes for the logged-out account. With no account left, also delete the + // device token so nothing registered under it is delivered any more. + disablePushRegistrations([{ username, accessToken }], { + deleteToken: _otherAccounts.length === 0, + // A replaced token must be registered again for whoever is still signed in. + onTokenReplaced: () => this._registerDeviceForNotifications(), + }); + if (_otherAccounts.length > 0) { const targetAccount = _otherAccounts[0]; await this._switchAccount(targetAccount); @@ -1105,16 +1203,34 @@ class ApplicationContainer extends Component { dispatch(logoutDone()); Alert.alert(intl.formatMessage({ id: 'alert.fail' }), (err as any).message); this._repairUserAccountData(username); + } finally { + this._departingAccounts.delete(username); } }; _enableNotification = async ( - username: any, - isEnable: any, - settings = null, - encAccesstoken = null, + username: string, + isEnable: boolean, + settings: any, + encAccesstoken: string, ) => { - const accessToken = encAccesstoken ? decryptKey(encAccesstoken, Config.DEFAULT_PIN) : null; + const { pinCode } = this.props; + const accessToken = decryptAccessToken(encAccesstoken, pinCode); + + if (!accessToken) { + // The request would be rejected without it. A stored token that does not decrypt + // means the PIN state and the stored keys disagree, which is worth knowing about. + this._reportPushProblem(`decrypt:${username}`, () => + captureMessage( + 'Push registration skipped: stored access token did not decrypt', + (scope) => { + scope.setTag('context', 'push-registration'); + scope.setFingerprint(['push-registration-decrypt']); + }, + ), + ); + return; + } // compile notify_types let notify_types: any[] = []; @@ -1168,16 +1284,53 @@ class ApplicationContainer extends Component { return; } - const token = await getMessaging().getToken(); - console.log('FCM Token obtained:', !!token); - saveNotificationSetting( - accessToken!, - username, - `fcm-${Platform.OS}`, - Number(isEnable), - notify_types, - token, - ); + // A logout may still be disabling rows or replacing this token: register after it, + // and only if the account is still signed in by then. If the wait gave up, register + // once more when every logout has finished, so a late disable is not the last write. + const registration = await beginPushRegistration({ + stillWanted: () => !!this._signedInPushAccount(username), + onReleaseSettled: () => this._registerAgainIfSignedIn(username), + }); + if (!registration) { + return; + } + console.log('FCM Token obtained:', !!registration.token); + try { + await saveNotificationSetting( + accessToken, + username, + getPushSystem(), + Number(isEnable), + notify_types, + registration.token, + ); + this._rejectedPushRegistrations.delete(username); + } catch (error) { + const status = (error as any)?.status; + if (typeof status !== 'number') { + // Offline or timed out: the next start, reconnect or daily refresh retries. + console.warn('Push registration request failed', error); + return; + } + if (status === 401 || status === 403) { + if (this._signedInPushAccount(username)?.local?.accessToken !== encAccesstoken) { + // The token was renewed while this request was out: retry with the new one. + this._registerAgainIfSignedIn(username); + } else { + // Retried when this account's access token is renewed (componentDidUpdate). + this._rejectedPushRegistrations.add(username); + } + } + this._reportPushProblem(`http:${status}:${username}`, () => + captureException(error, (scope) => { + scope.setTag('context', 'push-registration'); + scope.setTag('status', String(status)); + scope.setFingerprint(['push-registration-http', String(status)]); + }), + ); + } finally { + registration.finish(); + } } catch (error) { // Handle platform-specific FCM errors gracefully const errorMessage = (error as any).message || ''; @@ -1190,9 +1343,12 @@ class ApplicationContainer extends Component { Platform.OS === 'android' && (errorMessage.includes('MISSING_INSTANCEID_SERVICE') || errorMessage.includes('SERVICE_NOT_AVAILABLE') || - errorMessage.includes('AUTHENTICATION_FAILED')) + errorMessage.includes('AUTHENTICATION_FAILED') || + errorMessage.includes('FIS_AUTH_ERROR')) ) { // Android: Google Play Services issues (common on emulators, custom ROMs, outdated devices) + // FIS_AUTH_ERROR is Firebase Installations failing to authenticate the device + // (ECENCY-MOBILE-28Y); the app cannot fix it either. console.log( 'Google Play Services not available or misconfigured - FCM disabled for this device', ); diff --git a/src/screens/login/container/loginContainer.tsx b/src/screens/login/container/loginContainer.tsx index 19edd28eac..ab8a8c91bb 100644 --- a/src/screens/login/container/loginContainer.tsx +++ b/src/screens/login/container/loginContainer.tsx @@ -1,15 +1,14 @@ import React, { PureComponent } from 'react'; -import { Alert, Platform } from 'react-native'; +import { Alert } from 'react-native'; import { connect } from 'react-redux'; import { injectIntl } from 'react-intl'; import Config from 'react-native-config'; -import { getMessaging } from '@react-native-firebase/messaging'; // Services and Actions import { useNavigation } from '@react-navigation/native'; import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; import { SheetManager } from 'react-native-actions-sheet'; -import { getAccountsQueryOptions, saveNotificationSetting } from '@ecency/sdk'; +import { getAccountsQueryOptions } from '@ecency/sdk'; import { captureException } from '../../../utils/sentryUtils'; import { getQueryClient } from '../../../providers/queries'; import { login, loginWithSC2 } from '../../../providers/hive/auth'; @@ -22,7 +21,7 @@ import { } from '../../../redux/actions/accountAction'; import { login as loginAction, setPinCode } from '../../../redux/actions/applicationActions'; import { setInitPosts, setFeedPosts } from '../../../redux/actions/postsAction'; -import { setPushTokenSaved, setExistUser } from '../../../storage/storage'; +import { setExistUser } from '../../../storage/storage'; import { decodeBase64, encryptKey } from '../../../utils/crypto'; // Middleware @@ -44,8 +43,6 @@ import { selectIsPinCodeOpen, selectIsConnected, selectPrevLoggedInUsers, - selectNotificationDetails, - selectIsNotificationOpen, } from '../../../redux/selectors'; /* @@ -200,7 +197,6 @@ class LoginContainer extends PureComponent { // track user activity for login userActivityMutation.mutate({ pointsTy: PointActivityIds.LOGIN }); setExistUser(true); - this._setPushToken(result.name, result.accessToken); const encryptedPin = encryptKey(Config.DEFAULT_PIN!, Config.PIN_KEY!); dispatch(setPinCode(encryptedPin)); @@ -241,69 +237,6 @@ class LoginContainer extends PureComponent { }); }; - _setPushToken = async (username: any, accessToken?: string) => { - const { notificationSettings, notificationDetails } = this.props; - const notifyTypesConst = { - vote: 1, - mention: 2, - follow: 3, - comment: 4, - reblog: 5, - transfers: 6, - favorite: 13, - bookmark: 15, - tags: 23, - delegations: 10, - payouts: 19, - accountUpdate: 20, - weeklyEarnings: 21, - scheduledPublished: 22, - }; - const notifyTypes: any[] = []; - - Object.keys(notificationDetails).forEach((item) => { - const notificationType = item.replace('Notification', ''); - const notifyType = (notifyTypesConst as any)[notificationType]; - - // Only a mapped type: a settings key this map does not know would otherwise - // register as null and the device would be told nothing useful about it. - if (notificationDetails[item] && notifyType) { - notifyTypes.push(notifyType); - } - }); - - if (!accessToken) { - console.warn('Missing access token for notifications:', username); - return; - } - - getMessaging() - .getToken() - .then((token) => { - const data = { - username, - token, - system: `fcm-${Platform.OS}`, - allows_notify: Number(notificationSettings), - notify_types: notifyTypes, - }; - return saveNotificationSetting( - accessToken, - data.username, - data.system, - data.allows_notify, - data.notify_types, - data.token, - ); - }) - .then(() => { - setPushTokenSaved(true); - }) - .catch((err) => { - console.warn('Failed to register push token', err); - }); - }; - _getAccountsWithUsername = async (username: any) => { const { isConnected } = this.props; @@ -348,8 +281,6 @@ class LoginContainer extends PureComponent { const mapStateToProps = (state: any) => ({ account: state.accounts, - notificationDetails: selectNotificationDetails(state), - notificationSettings: selectIsNotificationOpen(state), isConnected: selectIsConnected(state), isPinCodeOpen: selectIsPinCodeOpen(state), prevLoggedInUsers: selectPrevLoggedInUsers(state), diff --git a/src/screens/pinCode/container/pinCodeContainer.tsx b/src/screens/pinCode/container/pinCodeContainer.tsx index 3c8726959e..e0f3e3bee1 100644 --- a/src/screens/pinCode/container/pinCodeContainer.tsx +++ b/src/screens/pinCode/container/pinCodeContainer.tsx @@ -34,6 +34,7 @@ import { // Utils import { encryptKey, decryptKey } from '../../../utils/crypto'; import MigrationHelpers from '../../../utils/migrationHelpers'; +import { disablePushRegistrations, getPushAccounts } from '../../../utils/pushRegistration'; // Component import PinCodeView from '../children/pinCodeView'; @@ -306,7 +307,13 @@ class PinCodeContainer extends Component { }; _forgotPinCode = async () => { - const { otherAccounts, dispatch } = this.props; + const { otherAccounts, currentAccount, applicationPinCode, dispatch } = this.props; + + // Every account leaves the device: stop their pushes and drop the device token. + // Tokens are read now, before the data below is wiped. + disablePushRegistrations(getPushAccounts(currentAccount, otherAccounts, applicationPinCode), { + deleteToken: true, + }); await removeAllUserData() .then(async () => { diff --git a/src/screens/settings/container/settingsContainer.tsx b/src/screens/settings/container/settingsContainer.tsx index 69b5098848..4ae41d3357 100644 --- a/src/screens/settings/container/settingsContainer.tsx +++ b/src/screens/settings/container/settingsContainer.tsx @@ -102,6 +102,7 @@ import settingsTypes from '../../../constants/settingsTypes'; import { sendEmail } from '../../../utils/sendEmail'; import { encryptKey, decryptKey } from '../../../utils/crypto'; import { openStoreListing } from '../../../utils/storeReview'; +import { disablePushRegistrations, getPushAccounts } from '../../../utils/pushRegistration'; // Component import SettingsScreen from '../screen/settingsScreen'; @@ -798,7 +799,13 @@ class SettingsContainer extends Component { }; _clearUserData = async () => { - const { otherAccounts, dispatch } = this.props; + const { otherAccounts, currentAccount, pinCode, dispatch } = this.props; + + // Every account leaves the device: stop their pushes and drop the device token. + // Tokens are read now, before the data below is wiped. + disablePushRegistrations(getPushAccounts(currentAccount, otherAccounts, pinCode), { + deleteToken: true, + }); await removeAllUserData() .then(async () => { diff --git a/src/utils/pushRegistration.test.ts b/src/utils/pushRegistration.test.ts new file mode 100644 index 0000000000..0c112e1ae6 --- /dev/null +++ b/src/utils/pushRegistration.test.ts @@ -0,0 +1,502 @@ +// Real crypto, so the PIN fallbacks are exercised against actual ciphertext. +import { getMessaging } from '@react-native-firebase/messaging'; +import { saveNotificationSetting } from '@ecency/sdk'; +import { encryptKey } from './crypto'; +import { + decryptAccessToken, + disablePushRegistrations, + getPushAccounts, + beginPushRegistration, + getPushSystem, + getSignedInAccounts, + PushAccount, + waitForPushRelease, +} from './pushRegistration'; + +jest.unmock('crypto-js'); + +jest.mock('react-native-config', () => ({ DEFAULT_PIN: 'default-pin', PIN_KEY: 'pin-key' })); + +jest.mock('@ecency/sdk', () => ({ saveNotificationSetting: jest.fn() })); + +const mockMessaging = { + getToken: jest.fn(), + deleteToken: jest.fn(), +}; +jest.mock('@react-native-firebase/messaging', () => ({ + getMessaging: jest.fn(() => mockMessaging), +})); + +const saveMock = saveNotificationSetting as jest.Mock; + +const DEFAULT_PIN = 'default-pin'; +const USER_PIN = '112233'; +const appPin = (pin: string) => encryptKey(pin, 'pin-key'); + +beforeEach(() => { + jest.clearAllMocks(); + (getMessaging as jest.Mock).mockImplementation(() => mockMessaging); + mockMessaging.getToken.mockResolvedValue('fcm-token'); + mockMessaging.deleteToken.mockResolvedValue(undefined); + saveMock.mockResolvedValue({}); +}); + +describe('decryptAccessToken', () => { + it('returns undefined when there is no stored token', () => { + expect(decryptAccessToken(undefined, appPin(DEFAULT_PIN))).toBeUndefined(); + expect(decryptAccessToken('', appPin(DEFAULT_PIN))).toBeUndefined(); + }); + + it('decrypts a token stored under DEFAULT_PIN', () => { + const stored = encryptKey('code-1', DEFAULT_PIN); + expect(decryptAccessToken(stored, appPin(DEFAULT_PIN))).toBe('code-1'); + }); + + it("decrypts a token still stored under the user's own PIN", () => { + // An account not unlocked since the DEFAULT_PIN migration. Decrypting with + // DEFAULT_PIN alone, as registration used to, fails here. + const stored = encryptKey('code-2', USER_PIN); + expect(decryptAccessToken(stored, appPin(USER_PIN))).toBe('code-2'); + }); + + it('falls back to DEFAULT_PIN when the app PIN does not match the token', () => { + // A fresh login stores the token under DEFAULT_PIN before the app PIN is rewritten. + const stored = encryptKey('code-3', DEFAULT_PIN); + expect(decryptAccessToken(stored, appPin(USER_PIN))).toBe('code-3'); + }); + + it('falls back to DEFAULT_PIN when no app PIN is stored', () => { + const stored = encryptKey('code-4', DEFAULT_PIN); + expect(decryptAccessToken(stored, undefined)).toBe('code-4'); + }); + + it('returns undefined when neither PIN decrypts the token', () => { + const stored = encryptKey('code-5', 'some-other-pin'); + expect(decryptAccessToken(stored, appPin(USER_PIN))).toBeUndefined(); + expect(decryptAccessToken(stored, appPin(DEFAULT_PIN))).toBeUndefined(); + }); +}); + +describe('getPushAccounts', () => { + const account = (name: string, code?: string, keyedBy: 'name' | 'username' = 'username') => ({ + [keyedBy]: name, + local: code ? { accessToken: encryptKey(code, DEFAULT_PIN) } : {}, + }); + + it('lists each account once with its decrypted token', () => { + const current = { name: 'alice', local: { accessToken: encryptKey('alice-new', DEFAULT_PIN) } }; + const others = [account('alice', 'alice-old'), account('bob', 'bob-code')]; + + expect(getPushAccounts(current, others, appPin(DEFAULT_PIN))).toEqual([ + { username: 'alice', accessToken: 'alice-new' }, + { username: 'bob', accessToken: 'bob-code' }, + ]); + }); + + it('adds the current account when it is missing from otherAccounts', () => { + const current = { name: 'carol', local: { accessToken: encryptKey('carol', DEFAULT_PIN) } }; + + expect(getPushAccounts(current, [account('bob', 'bob-code')], appPin(DEFAULT_PIN))).toEqual([ + { username: 'bob', accessToken: 'bob-code' }, + { username: 'carol', accessToken: 'carol' }, + ]); + }); + + it("keeps the other-account token when the current account's entry has none", () => { + const current = { name: 'alice', local: {} }; + + expect(getPushAccounts(current, [account('alice', 'alice-old')], appPin(DEFAULT_PIN))).toEqual([ + { username: 'alice', accessToken: 'alice-old' }, + ]); + }); + + it('accepts entries keyed by name and skips entries with no name', () => { + const others = [account('dave', 'dave-code', 'name'), { local: {} }]; + + expect(getPushAccounts({}, others, appPin(DEFAULT_PIN))).toEqual([ + { username: 'dave', accessToken: 'dave-code' }, + ]); + }); +}); + +describe('getSignedInAccounts', () => { + it('includes the current account when otherAccounts does not list it', () => { + const current = { name: 'alice', local: { accessToken: 'enc-alice' } }; + const bob = { username: 'bob', local: { accessToken: 'enc-bob' } }; + + expect(getSignedInAccounts(current, [bob])).toEqual([ + { username: 'bob', account: bob }, + { username: 'alice', account: current }, + ]); + }); + + it('lists an account once when it is both current and in otherAccounts', () => { + const current = { name: 'alice', local: { accessToken: 'enc-new' } }; + const stored = { username: 'alice', local: { accessToken: 'enc-old' } }; + + expect(getSignedInAccounts(current, [stored])).toEqual([ + { username: 'alice', account: current }, + ]); + }); +}); + +const deferred = () => { + let resolve: () => void = () => undefined; + let reject: (err: Error) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const always = () => true; + +// Every test leaves the release chain and the in-flight set empty: each release it starts +// is awaited, and each registration it begins is finished. + +describe('disablePushRegistrations', () => { + it('disables every account with the current device token and keeps the token', async () => { + await disablePushRegistrations( + [ + { username: 'alice', accessToken: 'alice-code' }, + { username: 'carol', accessToken: 'carol-code' }, + ], + { deleteToken: false }, + ); + + expect(saveMock).toHaveBeenCalledTimes(2); + expect(saveMock).toHaveBeenCalledWith( + 'alice-code', + 'alice', + getPushSystem(), + 0, + [], + 'fcm-token', + ); + expect(saveMock).toHaveBeenCalledWith( + 'carol-code', + 'carol', + getPushSystem(), + 0, + [], + 'fcm-token', + ); + expect(mockMessaging.deleteToken).not.toHaveBeenCalled(); + }); + + it('deletes the device token only after every request has settled', async () => { + const events: string[] = []; + const alice = deferred(); + saveMock.mockImplementation((_code: string, username: string) => { + if (username === 'alice') { + return alice.promise.then(() => events.push('alice settled')); + } + events.push('bob settled'); + return Promise.reject(new Error('Request failed with status 500')); + }); + mockMessaging.deleteToken.mockImplementation(async () => { + events.push('token deleted'); + }); + + const done = disablePushRegistrations( + [ + { username: 'alice', accessToken: 'alice-code' }, + { username: 'bob', accessToken: 'bob-code' }, + ], + { deleteToken: true }, + ); + await settle(); + expect(mockMessaging.deleteToken).not.toHaveBeenCalled(); + + alice.resolve(); + await done; + expect(events).toEqual(['bob settled', 'alice settled', 'token deleted']); + }); + + it.each([ + ['a request fails', [{ username: 'alice', accessToken: 'a' }], true], + ['an account has no access token', [{ username: 'alice', accessToken: undefined }], false], + ])( + 'with accounts left, replaces the token when %s, so the row cannot stay on', + async (_case, accounts, failRequest) => { + if (failRequest) { + saveMock.mockRejectedValueOnce(new Error('Request failed with status 401')); + } + const onTokenReplaced = jest.fn(); + + await disablePushRegistrations(accounts, { deleteToken: false, onTokenReplaced }); + + expect(mockMessaging.deleteToken).toHaveBeenCalledTimes(1); + expect(onTokenReplaced).toHaveBeenCalledTimes(1); + }, + ); + + it('asks for registration after deleting the token with no account left', async () => { + const onTokenReplaced = jest.fn(); + await disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: true, + onTokenReplaced, + }); + expect(onTokenReplaced).toHaveBeenCalledTimes(1); + }); + + it('does not ask for registration when the token could not be deleted', async () => { + mockMessaging.deleteToken.mockRejectedValue(new Error('offline')); + const onTokenReplaced = jest.fn(); + + await expect( + disablePushRegistrations([], { deleteToken: true, onTokenReplaced }), + ).resolves.toBeUndefined(); + expect(mockMessaging.deleteToken).toHaveBeenCalledTimes(1); + expect(onTokenReplaced).not.toHaveBeenCalled(); + }); + + it('does nothing when the device has no token', async () => { + mockMessaging.getToken.mockRejectedValue(new Error('SERVICE_NOT_AVAILABLE')); + + await expect( + disablePushRegistrations([{ username: 'alice', accessToken: 'alice-code' }], { + deleteToken: true, + }), + ).resolves.toBeUndefined(); + expect(saveMock).not.toHaveBeenCalled(); + expect(mockMessaging.deleteToken).not.toHaveBeenCalled(); + }); + + it('survives a request that throws synchronously', async () => { + saveMock.mockImplementationOnce(() => { + throw new Error('synchronous failure'); + }); + + await disablePushRegistrations( + [ + { username: 'alice', accessToken: 'a' }, + { username: 'bob', accessToken: 'b' }, + ], + { deleteToken: false }, + ); + // Both requests were attempted, and the failed one replaced the token. + expect(saveMock).toHaveBeenCalledTimes(2); + expect(mockMessaging.deleteToken).toHaveBeenCalledTimes(1); + }); + + it('keeps the chain usable after a release fails unexpectedly', async () => { + // A malformed call throws inside the release itself. + await expect( + disablePushRegistrations(null as unknown as PushAccount[], { deleteToken: true }), + ).resolves.toBeUndefined(); + + await expect(waitForPushRelease(20)).resolves.toBe(true); + await disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: true, + }); + expect(mockMessaging.deleteToken).toHaveBeenCalledTimes(1); + }); + + it('waits for a registration already in flight before disabling', async () => { + const registration = await beginPushRegistration({ stillWanted: always }); + const release = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: false, + }); + await settle(); + // The registration's request is still out: the disable must not overtake it. + expect(saveMock).not.toHaveBeenCalled(); + + registration!.finish(); + await release; + expect(saveMock).toHaveBeenCalledTimes(1); + }); + + it('stops waiting for a registration that never finishes', async () => { + const registration = await beginPushRegistration({ stillWanted: always }); + await disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: false, + registrationWaitMs: 20, + }); + expect(saveMock).toHaveBeenCalledTimes(1); + registration!.finish(); + }); +}); + +describe('waitForPushRelease', () => { + it('resolves at once when nothing is being released', async () => { + await expect(waitForPushRelease(10_000)).resolves.toBe(true); + }); + + it('runs releases one after another', async () => { + const first = deferred(); + saveMock.mockImplementationOnce(() => first.promise); + const events: string[] = []; + mockMessaging.getToken.mockImplementation(async () => { + events.push('token read'); + return 'fcm-token'; + }); + mockMessaging.deleteToken.mockImplementation(async () => { + events.push('token deleted'); + }); + + const releaseA = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: true, + }); + const releaseB = disablePushRegistrations([{ username: 'bob', accessToken: 'b' }], { + deleteToken: false, + }); + await settle(); + expect(events).toEqual(['token read']); + + first.resolve(); + await Promise.all([releaseA, releaseB]); + expect(events).toEqual(['token read', 'token deleted', 'token read']); + }); + + it('waits for releases queued while it waits', async () => { + const first = deferred(); + const second = deferred(); + saveMock + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + const releaseA = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: false, + }); + let waited: boolean | undefined; + const waiting = waitForPushRelease(10_000).then((result) => { + waited = result; + }); + const releaseB = disablePushRegistrations([{ username: 'bob', accessToken: 'b' }], { + deleteToken: false, + }); + + first.resolve(); + await releaseA; + await settle(); + expect(waited).toBeUndefined(); + + second.resolve(); + await Promise.all([releaseB, waiting]); + expect(waited).toBe(true); + }); + + it('gives up after the timeout when a release never settles', async () => { + const stuck = deferred(); + saveMock.mockImplementation(() => stuck.promise); + + const release = disablePushRegistrations([{ username: 'alice', accessToken: 'code' }], { + deleteToken: true, + }); + await expect(waitForPushRelease(20)).resolves.toBe(false); + expect(mockMessaging.deleteToken).not.toHaveBeenCalled(); + + stuck.resolve(); + await release; + }); +}); + +describe('beginPushRegistration', () => { + it('reads the token once releases are done and the account is still wanted', async () => { + const registration = await beginPushRegistration({ stillWanted: always }); + expect(registration?.token).toBe('fcm-token'); + registration!.finish(); + }); + + it('gives up when the account left while a queued release ran', async () => { + // Logout A (slow), login B waits, logout B queues a second release before the first ends. + const first = deferred(); + saveMock.mockImplementationOnce(() => first.promise); + let signedIn = true; + const events: string[] = []; + mockMessaging.getToken.mockImplementation(async () => { + events.push('token read'); + return 'fcm-token'; + }); + mockMessaging.deleteToken.mockImplementation(async () => { + events.push('token deleted'); + }); + + const releaseA = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: true, + }); + const bob = beginPushRegistration({ stillWanted: () => signedIn }); + signedIn = false; + const releaseB = disablePushRegistrations([{ username: 'bob', accessToken: 'b' }], { + deleteToken: true, + }); + + first.resolve(); + await Promise.all([releaseA, releaseB]); + await expect(bob).resolves.toBeNull(); + // Only the two releases read the token, and both deleted it. + expect(events).toEqual(['token read', 'token deleted', 'token read', 'token deleted']); + }); + + it('finishes its in-flight mark when the token cannot be read', async () => { + mockMessaging.getToken.mockRejectedValueOnce(new Error('SERVICE_NOT_AVAILABLE')); + await expect(beginPushRegistration({ stillWanted: always })).rejects.toThrow( + 'SERVICE_NOT_AVAILABLE', + ); + + // A release does not wait for the failed registration. + await disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: false, + registrationWaitMs: 10_000, + }); + expect(saveMock).toHaveBeenCalledTimes(1); + }); + + it('asks to register again once every release has settled after a timed-out wait', async () => { + const stuck = deferred(); + const queued = deferred(); + saveMock + .mockImplementationOnce(() => stuck.promise) + .mockImplementationOnce(() => queued.promise); + const registerAgain = jest.fn(); + + const releaseA = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: true, + }); + const registration = await beginPushRegistration({ + stillWanted: always, + onReleaseSettled: registerAgain, + timeoutMs: 20, + }); + // The wait gave up: the registration proceeds, its retry waits for the releases. + expect(registration?.token).toBe('fcm-token'); + registration!.finish(); + const releaseB = disablePushRegistrations([{ username: 'bob', accessToken: 'b' }], { + deleteToken: false, + }); + + stuck.resolve(); + await releaseA; + await settle(); + expect(registerAgain).not.toHaveBeenCalled(); + + queued.resolve(); + await releaseB; + await settle(); + expect(registerAgain).toHaveBeenCalledTimes(1); + }); + + it('does not ask to register again when the wait did not time out', async () => { + const quick = deferred(); + saveMock.mockImplementationOnce(() => quick.promise); + const registerAgain = jest.fn(); + + const release = disablePushRegistrations([{ username: 'alice', accessToken: 'a' }], { + deleteToken: false, + }); + const pending = beginPushRegistration({ + stillWanted: always, + onReleaseSettled: registerAgain, + timeoutMs: 10_000, + }); + quick.resolve(); + await release; + const registration = await pending; + registration!.finish(); + await settle(); + + expect(registerAgain).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/pushRegistration.ts b/src/utils/pushRegistration.ts new file mode 100644 index 0000000000..1b150be928 --- /dev/null +++ b/src/utils/pushRegistration.ts @@ -0,0 +1,295 @@ +import { Platform } from 'react-native'; +import Config from 'react-native-config'; +import { getMessaging } from '@react-native-firebase/messaging'; +import { saveNotificationSetting } from '@ecency/sdk'; + +import { decryptKey } from './crypto'; + +// Coming back to the foreground re-registers at most this often, so the backend keeps +// seeing a live install even when the app is resumed for days without a cold start. +export const PUSH_REGISTRATION_REFRESH_MS = 24 * 60 * 60 * 1000; + +export const getPushSystem = () => `fcm-${Platform.OS}`; + +/** + * Decrypts a stored access token for a push registration request. + * + * Stored tokens are encrypted with the app PIN kept in redux (`application.pin`, + * itself encrypted with PIN_KEY). That is DEFAULT_PIN once an account is migrated, + * but an account that has not been unlocked since the migration still uses the + * user's own PIN, so decrypting with DEFAULT_PIN alone fails for it. DEFAULT_PIN + * stays as the fallback because a fresh login stores its token under DEFAULT_PIN + * and can reach here before the redux PIN is written. + */ +export const decryptAccessToken = ( + encAccessToken: string | null | undefined, + encAppPin: string | null | undefined, +): string | undefined => { + if (!encAccessToken) { + return undefined; + } + + const appPin = encAppPin ? decryptKey(encAppPin, Config.PIN_KEY) : undefined; + const accessToken = appPin ? decryptKey(encAccessToken, appPin) : undefined; + if (accessToken || appPin === Config.DEFAULT_PIN) { + return accessToken; + } + + return decryptKey(encAccessToken, Config.DEFAULT_PIN); +}; + +export interface PushAccount { + username: string; + accessToken?: string; +} + +/** + * Every account signed in on this device, once each. The current account is normally + * also in `otherAccounts`; its own entry is used when it carries a token (its `local` + * data is the freshest), and added if it was missing. + */ +export const getSignedInAccounts = (currentAccount: any, otherAccounts: any[] = []) => { + const accounts = new Map(); + otherAccounts.forEach((account) => { + const username = account?.username || account?.name; + if (username) { + accounts.set(username, account); + } + }); + if (currentAccount?.name && currentAccount.local?.accessToken) { + accounts.set(currentAccount.name, currentAccount); + } + + return Array.from(accounts, ([username, account]) => ({ username, account })); +}; + +/** Every account signed in on this device, with its decrypted access token. */ +export const getPushAccounts = ( + currentAccount: any, + otherAccounts: any[] = [], + encAppPin: string | null | undefined, +): PushAccount[] => + getSignedInAccounts(currentAccount, otherAccounts).map(({ username, account }) => ({ + username, + accessToken: decryptAccessToken(account?.local?.accessToken, encAppPin), + })); + +// Deregistrations (releases) run one after another on this chain, and registrations +// wait for it. It never rejects. +let pendingRelease: Promise = Promise.resolve(); + +// Registrations past their wait whose request has not settled yet. A release waits for +// them before it sends its disable requests. +const registrationsInFlight = new Set>(); + +export const PUSH_RELEASE_WAIT_MS = 30 * 1000; + +// Cold start and reconnect both register every account; within this window the second +// pass is skipped. +export const PUSH_REGISTRATION_DEDUPE_MS = 60 * 1000; + +/** Resolves to true when `promise` settles within `ms`, false otherwise. */ +const settlesWithin = async (promise: Promise, ms: number): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(ms, 0)); + }); + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + timeout, + ]); + } finally { + clearTimeout(timer); + } +}; + +interface ReleaseOptions { + deleteToken: boolean; + /** Called after the token was deleted, to register the accounts still signed in. */ + onTokenReplaced?: () => void; + /** How long to wait for registrations already in flight. */ + registrationWaitMs?: number; +} + +const releasePushRegistrations = async ( + accounts: PushAccount[], + { deleteToken, onTokenReplaced, registrationWaitMs = PUSH_RELEASE_WAIT_MS }: ReleaseOptions, +) => { + // A registration already on its way must land first, or it could switch a departing + // account back on after its disable. + await settlesWithin(Promise.all(registrationsInFlight), registrationWaitMs); + + let token: string; + try { + token = await getMessaging().getToken(); + } catch (err) { + // No token on this device (no Play Services, simulator, offline): nothing to disable. + console.warn('Push token unavailable, skipping push deregistration', err); + return; + } + + const disabled = await Promise.all( + accounts.map((account) => { + if (!account.username || !account.accessToken) { + return Promise.resolve(false); + } + // Inside then(), so even a synchronous throw becomes this account's failure. + return Promise.resolve() + .then(() => + saveNotificationSetting( + account.accessToken, + account.username, + getPushSystem(), + 0, + [], + token, + ), + ) + .then( + () => true, + (err) => { + console.warn('Failed to disable push notifications for', account.username, err); + return false; + }, + ); + }), + ); + + // With no account left the token goes. With accounts left it stays, unless a row could + // not be disabled: deleting the token is then the only way to stop that account's + // pushes, and the accounts still signed in register again with the new token. + if (!deleteToken && disabled.every(Boolean)) { + return; + } + + try { + await getMessaging().deleteToken(); + } catch (err) { + console.warn('Failed to delete push token', err); + return; + } + + if (onTokenReplaced) { + try { + onTokenReplaced(); + } catch (err) { + console.warn('Failed to register after replacing the push token', err); + } + } +}; + +/** + * Turns push off for accounts leaving this device. + * + * Each account's row is disabled with the current FCM token, after any registration + * already in flight has landed. The token is then deleted when no account is left, or + * when a row could not be disabled: the backend then gets "unregistered" for it and + * stops sending. The delete must come after the requests, because deleting first makes + * the next `getToken()` mint a new token and the requests would disable the wrong rows. + * + * Callers do not need to wait: the logout itself should not hang on the network. + * Registrations wait instead, through beginPushRegistration. + */ +export const disablePushRegistrations = ( + accounts: PushAccount[], + options: ReleaseOptions, +): Promise => { + const release = pendingRelease + .then(() => releasePushRegistrations(accounts, options)) + // Never rejects, so the chain stays usable for later releases and registrations. + .catch((err) => { + console.warn('Push deregistration failed', err); + }); + pendingRelease = release; + return release; +}; + +/** + * Resolves to true once no deregistration is in progress, or to false after `timeoutMs`. + * Releases queued while it waits count too: it returns only when the chain has stopped + * growing. + */ +export const waitForPushRelease = async (timeoutMs = PUSH_RELEASE_WAIT_MS): Promise => { + const release = pendingRelease; + const startedAt = Date.now(); + if (!(await settlesWithin(release, timeoutMs))) { + return false; + } + return release === pendingRelease + ? true + : waitForPushRelease(timeoutMs - (Date.now() - startedAt)); +}; + +const whenReleasesSettle = async (): Promise => { + const release = pendingRelease; + await release; + if (release !== pendingRelease) { + await whenReleasesSettle(); + } +}; + +export interface PushRegistration { + token: string; + /** Call once the registration request has settled, whatever its outcome. */ + finish: () => void; +} + +/** + * Starts a registration: waits for deregistrations, checks the account is still wanted, + * then reads the device token. + * + * Resolves to null when `stillWanted()` is false after the wait (the account left while + * it waited). Otherwise the caller sends its request and calls `finish()`; releases that + * start meanwhile wait for it before disabling anything. + * + * When the wait times out, releases are still running. Their disable requests may reach + * the backend after this registration, and a release may replace the token it read. + * Neither can be recalled, so `onReleaseSettled` is called once every release has + * settled, for the caller to register again. + */ +export const beginPushRegistration = async ({ + stillWanted, + onReleaseSettled, + timeoutMs = PUSH_RELEASE_WAIT_MS, +}: { + stillWanted: () => boolean; + onReleaseSettled?: () => void; + timeoutMs?: number; +}): Promise => { + const settled = await waitForPushRelease(timeoutMs); + if (!stillWanted()) { + return null; + } + + let resolveInFlight: () => void = () => undefined; + const inFlight = new Promise((resolve) => { + resolveInFlight = resolve; + }); + registrationsInFlight.add(inFlight); + const finish = () => { + registrationsInFlight.delete(inFlight); + resolveInFlight(); + }; + + if (!settled && onReleaseSettled) { + whenReleasesSettle().then(() => { + try { + onReleaseSettled(); + } catch (err) { + console.warn('Failed to register again after push deregistration', err); + } + }); + } + + try { + const token = await getMessaging().getToken(); + return { token, finish }; + } catch (err) { + finish(); + throw err; + } +};