diff --git a/__mocks__/react-native-device-info.js b/__mocks__/react-native-device-info.js new file mode 100644 index 000000000000..2ba5b6ef85b3 --- /dev/null +++ b/__mocks__/react-native-device-info.js @@ -0,0 +1,3 @@ +import MockDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock'; + +export default MockDeviceInfo; diff --git a/__mocks__/urbanairship-react-native.js b/__mocks__/urbanairship-react-native.js index ba380b6d4a72..5bc90f267bf2 100644 --- a/__mocks__/urbanairship-react-native.js +++ b/__mocks__/urbanairship-react-native.js @@ -21,6 +21,7 @@ const UrbanAirship = { removeAllListeners: jest.fn(), setBadgeNumber: jest.fn(), setForegroundPresentationOptions: jest.fn(), + getNotificationStatus: () => Promise.resolve({airshipOptIn: false, systemEnabled: false}), }; export default UrbanAirship; diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index 170d082cb479..ef110747a6a4 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -45,7 +45,6 @@ import java.util.concurrent.TimeUnit; public class CustomNotificationProvider extends ReactNotificationProvider { - // Resize icons to 100 dp x 100 dp private static final int MAX_ICON_SIZE_DPS = 100; diff --git a/desktop/ELECTRON_EVENTS.js b/desktop/ELECTRON_EVENTS.js index f0d9b865b01d..6a808bdb99aa 100644 --- a/desktop/ELECTRON_EVENTS.js +++ b/desktop/ELECTRON_EVENTS.js @@ -1,13 +1,14 @@ const ELECTRON_EVENTS = { + BLUR: 'blur', + FOCUS: 'focus', + LOCALE_UPDATED: 'locale-updated', + REQUEST_DEVICE_ID: 'requestDeviceID', + REQUEST_FOCUS_APP: 'requestFocusApp', REQUEST_UPDATE_BADGE_COUNT: 'requestUpdateBadgeCount', REQUEST_VISIBILITY: 'requestVisibility', - REQUEST_FOCUS_APP: 'requestFocusApp', SHOW_KEYBOARD_SHORTCUTS_MODAL: 'show-keyboard-shortcuts-modal', START_UPDATE: 'start-update', UPDATE_DOWNLOADED: 'update-downloaded', - FOCUS: 'focus', - BLUR: 'blur', - LOCALE_UPDATED: 'locale-updated', }; module.exports = ELECTRON_EVENTS; diff --git a/desktop/contextBridge.js b/desktop/contextBridge.js index f065c4caac21..231e0072c6c9 100644 --- a/desktop/contextBridge.js +++ b/desktop/contextBridge.js @@ -6,6 +6,7 @@ const { const ELECTRON_EVENTS = require('./ELECTRON_EVENTS'); const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [ + ELECTRON_EVENTS.REQUEST_DEVICE_ID, ELECTRON_EVENTS.REQUEST_FOCUS_APP, ELECTRON_EVENTS.REQUEST_UPDATE_BADGE_COUNT, ELECTRON_EVENTS.REQUEST_VISIBILITY, @@ -59,6 +60,21 @@ contextBridge.exposeInMainWorld('electron', { return ipcRenderer.sendSync(channel, data); }, + /** + * Execute a function in the main process and return a promise that resolves with its response. + * + * @param {String} channel + * @param {*} args + * @returns {Promise} + */ + invoke: (channel, ...args) => { + if (!_.contains(WHITELIST_CHANNELS_RENDERER_TO_MAIN, channel)) { + throw new Error(getErrorMessage(channel)); + } + + return ipcRenderer.invoke(channel, ...args); + }, + /** * Set up a listener for events emitted from the main process and sent to the renderer process. * diff --git a/desktop/main.js b/desktop/main.js index d4bfd9dd2fe1..9381ff0e2dfe 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -12,6 +12,7 @@ const serve = require('electron-serve'); const contextMenu = require('electron-context-menu'); const {autoUpdater} = require('electron-updater'); const log = require('electron-log'); +const {machineId} = require('node-machine-id'); const ELECTRON_EVENTS = require('./ELECTRON_EVENTS'); const checkForUpdates = require('../src/libs/checkForUpdates'); const CONFIG = require('../src/CONFIG').default; @@ -282,6 +283,8 @@ const mainWindow = (() => { titleBarStyle: 'hidden', }); + ipcMain.handle(ELECTRON_EVENTS.REQUEST_DEVICE_ID, () => machineId()); + /* * The default origin of our Electron app is app://- instead of https://new.expensify.com or https://staging.new.expensify.com * This causes CORS errors because the referer and origin headers are wrong and the API responds with an Access-Control-Allow-Origin that doesn't match app://- diff --git a/desktop/package-lock.json b/desktop/package-lock.json index df18cde3a3d1..abc1299154ef 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,7 +10,8 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.7", "electron-serve": "^1.0.0", - "electron-updater": "^4.3.4" + "electron-updater": "^4.3.4", + "node-machine-id": "^1.1.12" } }, "node_modules/@types/semver": { @@ -307,6 +308,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/node-machine-id": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", + "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -655,6 +661,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node-machine-id": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", + "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/desktop/package.json b/desktop/package.json index a31d0db4e5bd..45283a260970 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,8 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.7", "electron-serve": "^1.0.0", - "electron-updater": "^4.3.4" + "electron-updater": "^4.3.4", + "node-machine-id": "^1.1.12" }, "author": "Expensify, Inc.", "license": "MIT", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9e9ed15c25c4..fbedcb9c0022 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,18 +1,18 @@ PODS: - - Airship (16.8.0): - - Airship/Automation (= 16.8.0) - - Airship/Basement (= 16.8.0) - - Airship/Core (= 16.8.0) - - Airship/ExtendedActions (= 16.8.0) - - Airship/MessageCenter (= 16.8.0) - - Airship/Automation (16.8.0): + - Airship (16.10.7): + - Airship/Automation (= 16.10.7) + - Airship/Basement (= 16.10.7) + - Airship/Core (= 16.10.7) + - Airship/ExtendedActions (= 16.10.7) + - Airship/MessageCenter (= 16.10.7) + - Airship/Automation (16.10.7): - Airship/Core - - Airship/Basement (16.8.0) - - Airship/Core (16.8.0): + - Airship/Basement (16.10.7) + - Airship/Core (16.10.7): - Airship/Basement - - Airship/ExtendedActions (16.8.0): + - Airship/ExtendedActions (16.10.7): - Airship/Core - - Airship/MessageCenter (16.8.0): + - Airship/MessageCenter (16.10.7): - Airship/Core - boost (1.76.0) - CocoaAsyncSocket (7.6.5) @@ -575,6 +575,8 @@ PODS: - React-Core - RNDateTimePicker (3.5.2): - React-Core + - RNDeviceInfo (10.3.0): + - React-Core - RNFastImage (8.6.3): - React-Core - SDWebImage (~> 5.11.1) @@ -639,8 +641,8 @@ PODS: - libwebp (~> 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.6.0) - - urbanairship-react-native (14.4.1): - - Airship (= 16.8.0) + - urbanairship-react-native (14.6.1): + - Airship (= 16.10.7) - React-Core - Yoga (1.14.0) - YogaKit (1.18.1): @@ -730,6 +732,7 @@ DEPENDENCIES: - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" + - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNFastImage (from `../node_modules/react-native-fast-image`) - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" @@ -901,6 +904,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-picker/picker" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" + RNDeviceInfo: + :path: "../node_modules/react-native-device-info" RNFastImage: :path: "../node_modules/react-native-fast-image" RNFBAnalytics: @@ -929,7 +934,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - Airship: 4657c3d5118441240e04674d9445cbd6e363c956 + Airship: fbff646723323c58e3871cd30488612ca373f597 boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 @@ -1019,6 +1024,7 @@ SPEC CHECKSUMS: RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 RNCPicker: 0b65be85fe7954fbb2062ef079e3d1cde252d888 RNDateTimePicker: 7658208086d86d09e1627b5c34ba0cf237c60140 + RNDeviceInfo: 4701f0bf2a06b34654745053db0ce4cb0c53ada7 RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 RNFBAnalytics: f76bfa164ac235b00505deb9fc1776634056898c RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e @@ -1033,7 +1039,7 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 - urbanairship-react-native: 7e2e9a84c541b1d04798e51f7f390a2d5806eac0 + urbanairship-react-native: fe4d169332546a0efd348a009aa490dc36ff815e Yoga: f77f6497bccebdcbc8efb03dbf83eadfdec6d104 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a diff --git a/package-lock.json b/package-lock.json index 67bafbd043d0..59850baf2b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "react-native-blob-util": "^0.16.2", "react-native-collapsible": "^1.6.0", "react-native-config": "^1.4.5", + "react-native-device-info": "^10.3.0", "react-native-document-picker": "^8.0.0", "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.9.0", @@ -88,7 +89,7 @@ "semver": "^7.3.8", "shim-keyboard-event-key": "^1.0.3", "underscore": "^1.13.1", - "urbanairship-react-native": "^14.3.1" + "urbanairship-react-native": "^14.6.1" }, "devDependencies": { "@actions/core": "1.10.0", @@ -37373,6 +37374,14 @@ } } }, + "node_modules/react-native-device-info": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-10.3.0.tgz", + "integrity": "sha512-/ziZN1sA1REbJTv5mQZ4tXggcTvSbct+u5kCaze8BmN//lbxcTvWsU6NQd4IihLt89VkbX+14IGc9sVApSxd/w==", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-document-picker": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.1.1.tgz", @@ -42842,9 +42851,9 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/urbanairship-react-native": { - "version": "14.4.1", - "resolved": "https://registry.npmjs.org/urbanairship-react-native/-/urbanairship-react-native-14.4.1.tgz", - "integrity": "sha512-7CFEszUR5DZdmx4YfipiDzbKEF72cBCaEh3gZHjwtGY7gsctKBx057+V5b5988vM+UghHbiCGE5fHgWs2fQMUQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/urbanairship-react-native/-/urbanairship-react-native-14.6.1.tgz", + "integrity": "sha512-ddL3ZWZnhwCja9oMpq7YHEyuqca1IH34MtMm24w1SePzGRhcVAvKOe/lncIB1FAK6QyjG0pkPT5mu3vE/DsPEw==", "peerDependencies": { "react": "*", "react-native": "*" @@ -73305,6 +73314,12 @@ "integrity": "sha512-cSLdOfva2IPCxh6HjHN1IDVW9ratAvNnnAUx6ar2Byvr8KQU7++ysdFYPaoNVuJURuYoAKgvjab8ZcnwGZIO6Q==", "requires": {} }, + "react-native-device-info": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-10.3.0.tgz", + "integrity": "sha512-/ziZN1sA1REbJTv5mQZ4tXggcTvSbct+u5kCaze8BmN//lbxcTvWsU6NQd4IihLt89VkbX+14IGc9sVApSxd/w==", + "requires": {} + }, "react-native-document-picker": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.1.1.tgz", @@ -77445,9 +77460,9 @@ } }, "urbanairship-react-native": { - "version": "14.4.1", - "resolved": "https://registry.npmjs.org/urbanairship-react-native/-/urbanairship-react-native-14.4.1.tgz", - "integrity": "sha512-7CFEszUR5DZdmx4YfipiDzbKEF72cBCaEh3gZHjwtGY7gsctKBx057+V5b5988vM+UghHbiCGE5fHgWs2fQMUQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/urbanairship-react-native/-/urbanairship-react-native-14.6.1.tgz", + "integrity": "sha512-ddL3ZWZnhwCja9oMpq7YHEyuqca1IH34MtMm24w1SePzGRhcVAvKOe/lncIB1FAK6QyjG0pkPT5mu3vE/DsPEw==", "requires": {} }, "uri-js": { diff --git a/package.json b/package.json index 53df3bda119f..a8564dbf3092 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "react-native-blob-util": "^0.16.2", "react-native-collapsible": "^1.6.0", "react-native-config": "^1.4.5", + "react-native-device-info": "^10.3.0", "react-native-document-picker": "^8.0.0", "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.9.0", @@ -119,7 +120,7 @@ "semver": "^7.3.8", "shim-keyboard-event-key": "^1.0.3", "underscore": "^1.13.1", - "urbanairship-react-native": "^14.3.1" + "urbanairship-react-native": "^14.6.1" }, "devDependencies": { "@actions/core": "1.10.0", diff --git a/src/CONST.js b/src/CONST.js index 10f2a7e63dec..5bd655d59706 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -460,6 +460,7 @@ const CONST = { KYC_MIGRATION: 'expensify_migration_2020_04_28_RunKycVerifications', PREFERRED_EMOJI_SKIN_TONE: 'expensify_preferredEmojiSkinTone', FREQUENTLY_USED_EMOJIS: 'expensify_frequentlyUsedEmojis', + PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', }, DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index dc3f44e738fc..31c2edc9a051 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -15,6 +15,9 @@ export default { // which tab is the leader, and which ones are the followers ACTIVE_CLIENTS: 'activeClients', + // A unique ID for the device + DEVICE_ID: 'deviceID', + // Boolean flag set whenever the sidebar has loaded IS_SIDEBAR_LOADED: 'isSidebarLoaded', @@ -79,6 +82,9 @@ export default { // Contains the users's block expiration (if they have one) NVP_BLOCKED_FROM_CONCIERGE: 'private_blockedFromConcierge', + // Does this user have push notifications enabled for this device? + PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', + // Plaid data (access tokens, bank accounts ...) PLAID_DATA: 'plaidData', diff --git a/src/libs/Notification/PushNotification/index.native.js b/src/libs/Notification/PushNotification/index.native.js index b6787858191d..dbdf7183b37d 100644 --- a/src/libs/Notification/PushNotification/index.native.js +++ b/src/libs/Notification/PushNotification/index.native.js @@ -1,9 +1,18 @@ import _ from 'underscore'; import {AppState} from 'react-native'; +import Onyx from 'react-native-onyx'; import {UrbanAirship, EventType, iOS} from 'urbanairship-react-native'; import lodashGet from 'lodash/get'; import Log from '../../Log'; import NotificationType from './NotificationType'; +import * as PushNotification from '../../actions/PushNotification'; +import ONYXKEYS from '../../../ONYXKEYS'; + +let isUserOptedInToPushNotifications = false; +Onyx.connect({ + key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, + callback: val => isUserOptedInToPushNotifications = val, +}); const notificationEventActionMap = {}; @@ -45,6 +54,22 @@ function pushNotificationEventCallback(eventType, notification) { action(payload); } +/** + * Check if a user is opted-in to push notifications on this device and update the `pushNotificationsEnabled` NVP accordingly. + */ +function refreshNotificationOptInStatus() { + UrbanAirship.getNotificationStatus() + .then((notificationStatus) => { + const isOptedIn = notificationStatus.airshipOptIn && notificationStatus.systemEnabled; + if (isOptedIn === isUserOptedInToPushNotifications) { + return; + } + + Log.info('[PUSH_NOTIFICATION] Push notification opt-in status changed.', false, {isOptedIn}); + PushNotification.setPushNotificationOptInStatus(isOptedIn); + }); +} + /** * Register push notification callbacks. This is separate from namedUser registration because it needs to be executed * from a headless JS process, outside of any react lifecycle. @@ -55,6 +80,11 @@ function pushNotificationEventCallback(eventType, notification) { function init() { // Setup event listeners UrbanAirship.addListener(EventType.PushReceived, (notification) => { + // By default, refresh notification opt-in status to true if we receive a notification + if (!isUserOptedInToPushNotifications) { + PushNotification.setPushNotificationOptInStatus(true); + } + // If a push notification is received while the app is in foreground, // we'll assume pusher is connected so we'll ignore it and not fetch the same data twice. if (AppState.currentState === 'active') { @@ -71,6 +101,9 @@ function init() { pushNotificationEventCallback(EventType.NotificationResponse, event.notification); }); + // Keep track of which users have enabled push notifications via an NVP. + UrbanAirship.addListener(EventType.NotificationOptInStatus, refreshNotificationOptInStatus); + // This statement has effect on iOS only. // It enables the App to display push notifications when the App is in foreground. // By default, the push notifications are silenced on iOS if the App is in foreground. @@ -107,6 +140,9 @@ function register(accountID) { // Regardless of the user's opt-in status, we still want to receive silent push notifications. Log.info(`[PUSH_NOTIFICATIONS] Subscribing to notifications for account ID ${accountID}`); UrbanAirship.setNamedUser(accountID.toString()); + + // Refresh notification opt-in status NVP for the new user. + refreshNotificationOptInStatus(); } /** diff --git a/src/libs/actions/Device/generateDeviceID/index.android.js b/src/libs/actions/Device/generateDeviceID/index.android.js new file mode 100644 index 000000000000..f61b860bda7d --- /dev/null +++ b/src/libs/actions/Device/generateDeviceID/index.android.js @@ -0,0 +1,32 @@ +import DeviceInfo from 'react-native-device-info'; +import Str from 'expensify-common/lib/str'; + +const deviceID = DeviceInfo.getDeviceId(); +const uniqueID = Str.guid(deviceID); + +/** + * Get the "unique ID of the device". Note that the hardware ID provided by react-native-device-info for Android is considered private information, + * so using it without appropriate permissions could cause our app to be unlisted from the Google Play Store: + * + * - https://developer.android.com/training/articles/user-data-ids#kotlin + * = https://support.google.com/googleplay/android-developer/answer/10144311 + * + * Therefore, this deviceID is not truly unique, but will be a new GUID each time the app runs (we work around this limitation by saving it in Onyx). + * + * This GUID is stored in Onyx under ONYXKEYS.DEVICE_ID and is preserved on logout, such that the deviceID will only change if: + * + * - The user uninstalls and reinstalls the app (Android), OR + * - The user manually clears Onyx data + * + * While this isn't perfect, it's the best we can do without violating the Google Play Store guidelines, and it's probably good enough for most real-world users. + * + * Furthermore, the deviceID prefix is not unique to a specific device, but is likely to change from one type of device to another. + * Including this prefix will tell us with a reasonable degree of confidence if the user just uninstalled and reinstalled the app, or if they got a new device. + * + * @returns {Promise} + */ +function generateDeviceID() { + return Promise.resolve(uniqueID); +} + +export default generateDeviceID; diff --git a/src/libs/actions/Device/generateDeviceID/index.desktop.js b/src/libs/actions/Device/generateDeviceID/index.desktop.js new file mode 100644 index 000000000000..26de25e326e8 --- /dev/null +++ b/src/libs/actions/Device/generateDeviceID/index.desktop.js @@ -0,0 +1,12 @@ +import ELECTRON_EVENTS from '../../../../../desktop/ELECTRON_EVENTS'; + +/** + * Get the unique ID of the current device. This should remain the same even if the user uninstalls and reinstalls the app. + * + * @returns {Promise} + */ +function generateDeviceID() { + return window.electron.invoke(ELECTRON_EVENTS.REQUEST_DEVICE_ID); +} + +export default generateDeviceID; diff --git a/src/libs/actions/Device/generateDeviceID/index.ios.js b/src/libs/actions/Device/generateDeviceID/index.ios.js new file mode 100644 index 000000000000..943971c5ba45 --- /dev/null +++ b/src/libs/actions/Device/generateDeviceID/index.ios.js @@ -0,0 +1,15 @@ +import DeviceInfo from 'react-native-device-info'; + +const deviceID = DeviceInfo.getDeviceId(); + +/** + * Get the unique ID of the current device. This should remain the same even if the user uninstalls and reinstalls the app. + * + * @returns {Promise} + */ +function generateDeviceID() { + return DeviceInfo.getUniqueId() + .then(uniqueID => `${deviceID}_${uniqueID}`); +} + +export default generateDeviceID; diff --git a/src/libs/actions/Device/generateDeviceID/index.website.js b/src/libs/actions/Device/generateDeviceID/index.website.js new file mode 100644 index 000000000000..b8abc4734134 --- /dev/null +++ b/src/libs/actions/Device/generateDeviceID/index.website.js @@ -0,0 +1,23 @@ +import Str from 'expensify-common/lib/str'; + +const uniqueID = Str.guid(); + +/** + * Get the "unique ID of the device". + * Note deviceID is not truly unique but will be a new GUID each time the app runs (we work around this limitation by saving it in Onyx) + * + * This GUID is stored in Onyx under ONYXKEYS.DEVICE_ID and is preserved on logout, such that the deviceID will only change if: + * + * - The user opens the app on a different browser or in an incognito window, OR + * - The user manually clears Onyx data + * + * While this isn't perfect, it's just as good as any other obvious web solution, such as this one https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId + * which is also different/reset under the same circumstances + * + * @returns {Promise} + */ +function generateDeviceID() { + return Promise.resolve(uniqueID); +} + +export default generateDeviceID; diff --git a/src/libs/actions/Device/index.js b/src/libs/actions/Device/index.js new file mode 100644 index 000000000000..f56b5941c9d0 --- /dev/null +++ b/src/libs/actions/Device/index.js @@ -0,0 +1,50 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; +import Log from '../../Log'; +import generateDeviceID from './generateDeviceID'; + +let deviceID; + +/** + * @returns {Promise} + */ +function getDeviceID() { + return new Promise((resolve) => { + if (deviceID) { + return resolve(deviceID); + } + + const connectionID = Onyx.connect({ + key: ONYXKEYS.DEVICE_ID, + callback: (ID) => { + Onyx.disconnect(connectionID); + deviceID = ID; + return resolve(ID); + }, + }); + }); +} + +/** + * Saves a unique deviceID into Onyx. + */ +function setDeviceID() { + getDeviceID() + .then((existingDeviceID) => { + if (!existingDeviceID) { + return Promise.resolve(); + } + throw new Error(existingDeviceID); + }) + .then(generateDeviceID) + .then((uniqueID) => { + Log.info('Got new deviceID', false, uniqueID); + Onyx.set(ONYXKEYS.DEVICE_ID, uniqueID); + }) + .catch(err => Log.info('Found existing deviceID', false, err.message)); +} + +export { + getDeviceID, + setDeviceID, +}; diff --git a/src/libs/actions/PushNotification.js b/src/libs/actions/PushNotification.js new file mode 100644 index 000000000000..168b4fb9e169 --- /dev/null +++ b/src/libs/actions/PushNotification.js @@ -0,0 +1,43 @@ +import Onyx from 'react-native-onyx'; +import CONST from '../../CONST'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as API from '../API'; +import * as Device from './Device'; + +let isUserOptedInToPushNotifications = false; +Onyx.connect({ + key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, + callback: val => isUserOptedInToPushNotifications = val, +}); + +/** + * Record that user opted-in or opted-out of push notifications on the current device. + * + * @param {Boolean} isOptingIn + */ +function setPushNotificationOptInStatus(isOptingIn) { + Device.getDeviceID() + .then((deviceID) => { + const commandName = isOptingIn ? 'OptInToPushNotifications' : 'OptOutOfPushNotifications'; + const optimisticData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, + value: isOptingIn, + }, + ]; + const failureData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, + value: isUserOptedInToPushNotifications, + }, + ]; + API.write(commandName, {deviceID}, {optimisticData, failureData}); + }); +} + +export { + // eslint-disable-next-line import/prefer-default-export + setPushNotificationOptInStatus, +}; diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.js index c7b1079c04f4..7abcd94337bb 100644 --- a/src/libs/actions/SignInRedirect.js +++ b/src/libs/actions/SignInRedirect.js @@ -29,6 +29,7 @@ function clearStorageAndRedirect(errorMessage) { const keysToPreserve = []; keysToPreserve.push(ONYXKEYS.NVP_PREFERRED_LOCALE); keysToPreserve.push(ONYXKEYS.ACTIVE_CLIENTS); + keysToPreserve.push(ONYXKEYS.DEVICE_ID); // After signing out, set ourselves as offline if we were offline before logging out and we are not forcing it. // If we are forcing offline, ignore it while signed out, otherwise it would require a refresh because there's no way to toggle the switch to go back online while signed out. diff --git a/src/setup/index.js b/src/setup/index.js index 7cf509e1e3dd..c2b380f6485b 100644 --- a/src/setup/index.js +++ b/src/setup/index.js @@ -4,6 +4,7 @@ import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import platformSetup from './platformSetup'; import * as Metrics from '../libs/Metrics'; +import * as Device from '../libs/actions/Device'; export default function () { /* @@ -40,6 +41,8 @@ export default function () { }, }); + Device.setDeviceID(); + // Force app layout to work left to right because our design does not currently support devices using this mode I18nManager.allowRTL(false); I18nManager.forceRTL(false); diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.js index 73c5f300f71d..8096b0a0a762 100644 --- a/tests/unit/NetworkTest.js +++ b/tests/unit/NetworkTest.js @@ -19,6 +19,8 @@ import * as SequentialQueue from '../../src/libs/Network/SequentialQueue'; import * as MainQueue from '../../src/libs/Network/MainQueue'; import * as Request from '../../src/libs/Request'; +jest.mock('../../src/libs/Log'); + Onyx.init({ keys: ONYXKEYS, });