diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 0a8ef849f..f5bb583c7 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -60,6 +60,7 @@ }, "com.unarin.cordova.beacon": {}, "cordova-plugin-ionic-keyboard": {}, + "cordova-clipboard": {}, "cordova-plugin-app-version": {}, "cordova-plugin-file": {}, "cordova-plugin-device": {}, @@ -116,6 +117,7 @@ "chartjs-plugin-annotation": "^3.0.1", "com.unarin.cordova.beacon": "github:e-mission/cordova-plugin-ibeacon", "cordova-android": "13.0.0", + "cordova-clipboard": "^1.3.0", "cordova-ios": "7.1.1", "cordova-plugin-advanced-http": "3.3.1", "cordova-plugin-androidx-adapter": "1.1.3", diff --git a/setup/setup_native.sh b/setup/setup_native.sh index 05624a693..a7c396ab2 100644 --- a/setup/setup_native.sh +++ b/setup/setup_native.sh @@ -121,7 +121,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|" npx cordova prepare$PLATFORMS -EXPECTED_COUNT=25 +EXPECTED_COUNT=26 INSTALLED_COUNT=`npx cordova plugin list | wc -l` echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT" if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ]; diff --git a/www/__tests__/dynamicConfig.test.ts b/www/__tests__/dynamicConfig.test.ts index 5693ab3fb..795706731 100644 --- a/www/__tests__/dynamicConfig.test.ts +++ b/www/__tests__/dynamicConfig.test.ts @@ -1,7 +1,8 @@ -import { getConfig, initByUser } from '../js/config/dynamicConfig'; - +import { getConfig, joinWithTokenOrUrl } from '../js/config/dynamicConfig'; import initializedI18next from '../js/i18nextInit'; import { storageClear } from '../js/plugin/storage'; +import i18next from '../js/i18nextInit'; + window['i18next'] = initializedI18next; beforeEach(() => { @@ -56,6 +57,8 @@ global.fetch = (url: string) => { }) as any; }; +const windowAlert = jest.spyOn(window, 'alert').mockImplementation(() => {}); + describe('dynamicConfig', () => { const fakeStudyName = 'gotham-city-transit'; const validStudyNrelCommute = 'nrel-commute'; @@ -65,9 +68,9 @@ describe('dynamicConfig', () => { it('should resolve with null since no config is set yet', async () => { await expect(getConfig()).resolves.toBeNull(); }); - it('should resolve with a valid config once initByUser is called for an nrel-commute token', async () => { + it('should resolve with a valid config once joinWithTokenOrUrl is called for an nrel-commute token', async () => { const validToken = `nrelop_${validStudyNrelCommute}_user1`; - await initByUser({ token: validToken }); + await joinWithTokenOrUrl(validToken); const config = await getConfig(); expect(config!.server.connectUrl).toBe('https://nrel-commute-openpath.nrel.gov/api/'); expect(config!.joined).toEqual({ @@ -77,9 +80,9 @@ describe('dynamicConfig', () => { }); }); - it('should resolve with a valid config once initByUser is called for a denver-casr token', async () => { + it('should resolve with a valid config once joinWithTokenOrUrl is called for a denver-casr token', async () => { const validToken = `nrelop_${validStudyDenverCasr}_test_user1`; - await initByUser({ token: validToken }); + await joinWithTokenOrUrl(validToken); const config = await getConfig(); expect(config!.server.connectUrl).toBe('https://denver-casr-openpath.nrel.gov/api/'); expect(config!.joined).toEqual({ @@ -90,39 +93,68 @@ describe('dynamicConfig', () => { }); }); - describe('initByUser', () => { + describe('joinWithTokenOrUrl', () => { // fake study (gotham-city-transit) - it('should error if the study is nonexistent', async () => { + it('returns false if the study is nonexistent', async () => { const fakeBatmanToken = `nrelop_${fakeStudyName}_batman`; - await expect(initByUser({ token: fakeBatmanToken })).rejects.toThrow(); + await expect(joinWithTokenOrUrl(fakeBatmanToken)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining(i18next.t('config.unable-download-config')), + ); }); // real study without subgroups (nrel-commute) - it('should error if the study exists but the token is invalid format', async () => { - const badToken1 = validStudyNrelCommute; // doesn't start with nrelop_ - await expect(initByUser({ token: badToken1 })).rejects.toThrow(); - const badToken2 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _ - await expect(initByUser({ token: badToken2 })).rejects.toThrow(); - const badToken3 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _ - await expect(initByUser({ token: badToken3 })).rejects.toThrow(); + it('returns false if the study exists but the token is invalid format', async () => { + const badToken1 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _ + await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining( + i18next.t('config.not-enough-parts-old-style', { token: badToken1 }), + ), + ); + + const badToken2 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _ + await expect(joinWithTokenOrUrl(badToken2)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining( + i18next.t('config.not-enough-parts-old-style', { token: badToken2 }), + ), + ); + + const badToken3 = `invalid_${validStudyNrelCommute}_user3`; // doesn't start with nrelop_ + await expect(joinWithTokenOrUrl(badToken3)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining(i18next.t('config.no-nrelop-start', { token: badToken3 })), + ); }); - it('should return true after successfully storing the config for a valid token', async () => { + + it('returns true after successfully storing the config for a valid token', async () => { const validToken = `nrelop_${validStudyNrelCommute}_user2`; - await expect(initByUser({ token: validToken })).resolves.toBe(true); + await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true); }); // real study with subgroups (denver-casr) - it('should error if the study uses subgroups but the token has no subgroup', async () => { + it('returns false if the study uses subgroups but the token has no subgroup', async () => { const tokenWithoutSubgroup = `nrelop_${validStudyDenverCasr}_user2`; - await expect(initByUser({ token: tokenWithoutSubgroup })).rejects.toThrow(); + await expect(joinWithTokenOrUrl(tokenWithoutSubgroup)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining( + i18next.t('config.not-enough-parts', { token: tokenWithoutSubgroup }), + ), + ); }); - it('should error if the study uses subgroups and the token is invalid format', async () => { + it('returns false if the study uses subgroups and the token is invalid format', async () => { const badToken1 = `nrelop_${validStudyDenverCasr}_test_`; // doesn't have user code after last _ - await expect(initByUser({ token: badToken1 })).rejects.toThrow(); + await expect(joinWithTokenOrUrl(badToken1)).resolves.toBe(false); + expect(windowAlert).toHaveBeenLastCalledWith( + expect.stringContaining( + i18next.t('config.not-enough-parts-old-style', { token: badToken1 }), + ), + ); }); - it('should return true after successfully storing the config for a valid token with subgroup', async () => { + it('returns true after successfully storing the config for a valid token with subgroup', async () => { const validToken = `nrelop_${validStudyDenverCasr}_test_user2`; - await expect(initByUser({ token: validToken })).resolves.toBe(true); + await expect(joinWithTokenOrUrl(validToken)).resolves.toBe(true); }); }); }); diff --git a/www/__tests__/opcode.test.ts b/www/__tests__/opcode.test.ts new file mode 100644 index 000000000..ebcce664d --- /dev/null +++ b/www/__tests__/opcode.test.ts @@ -0,0 +1,81 @@ +// * @example getTokenFromUrl('https://open-access-openpath.nrel.gov/join/') => nrelop_open-access_default_randomLongStringWith32Characters +// * @example getTokenFromUrl('emission://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random +// * @example getTokenFromUrl('nrelopenpath://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random + +import { getStudyNameFromToken, getSubgroupFromToken, getTokenFromUrl } from '../js/config/opcode'; +import AppConfig from '../js/types/appConfigTypes'; +describe('opcode', () => { + describe('getStudyNameFromToken', () => { + const token = 'nrelop_great-study_default_randomLongStringWith32Characters'; + it('returns the study name from a token', () => { + expect(getStudyNameFromToken(token)).toBe('great-study'); + }); + }); + + describe('getSubgroupFromToken', () => { + const amazingSubgroupToken = 'nrelop_great-study_amazing-subgroup_000'; + it('returns the subgroup from a token with valid subgroup', () => { + const fakeconfig = { + opcode: { + subgroups: ['amazing-subgroup', 'other-subgroup'], + }, + } as any as AppConfig; + expect(getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toBe('amazing-subgroup'); + }); + + it("throws error if token's subgroup is not in config", () => { + const fakeconfig = { + opcode: { + subgroups: ['sad-subgroup', 'other-subgroup'], + }, + } as any as AppConfig; + expect(() => getSubgroupFromToken(amazingSubgroupToken, fakeconfig)).toThrow(); + }); + + it("returns 'default' if token has 'default' and config is not configured with subgroups", () => { + const defaultSubgroupToken = 'nrelop_great-study_default_000'; + const fakeconfig = { + opcode: {}, + } as any as AppConfig; + expect(getSubgroupFromToken(defaultSubgroupToken, fakeconfig)).toBe('default'); + }); + + it("throws error if token's subgroup is not 'default' and config is not configured with subgroups", () => { + const invalidSubgroupToken = 'nrelop_great-study_imaginary-subgroup_000'; + const fakeconfig = { + opcode: {}, + } as any as AppConfig; + expect(() => getSubgroupFromToken(invalidSubgroupToken, fakeconfig)).toThrow(); + }); + }); + + describe('getTokenFromUrl', () => { + it('generates a token for an nrel.gov join page URL', () => { + const url = 'https://open-access-openpath.nrel.gov/join/'; + expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_default_[a-zA-Z0-9]{32}$/); + }); + + it('generates a token for an nrel.gov join page URL with a sub_group parameter', () => { + const url = 'https://open-access-openpath.nrel.gov/join/?sub_group=foo'; + expect(getTokenFromUrl(url)).toMatch(/^nrelop_open-access_foo_[a-zA-Z0-9]{32}$/); + }); + + it('generates a token for an emission://join URL', () => { + const url = 'emission://join?study_config=great-study'; + expect(getTokenFromUrl(url)).toMatch(/^nrelop_great-study_default_[a-zA-Z0-9]{32}$/); + }); + + it('extracts the token from a nrelopenpath://login_token URL', () => { + const url = 'nrelopenpath://login_token?token=nrelop_study_subgroup_random'; + expect(getTokenFromUrl(url)).toBe('nrelop_study_subgroup_random'); + }); + + it('throws error for any URL with a path other than "join" or "login_token"', () => { + expect(() => getTokenFromUrl('https://open-access-openpath.nrel.gov/invalid/')).toThrow(); + expect(() => getTokenFromUrl('nrelopenpath://jion?study_config=open-access')).toThrow(); + expect(() => + getTokenFromUrl('emission://togin_loken?token=nrelop_open-access_000'), + ).toThrow(); + }); + }); +}); diff --git a/www/i18n/en.json b/www/i18n/en.json index afd4e2600..ec432d89e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -404,7 +404,8 @@ "all-green-status": "Make sure that all status checks are green", "dont-force-kill": "Do not force kill the app", "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", - "close": "Close" + "close": "Close", + "proceeding-with-token": "Proceeding with OPcode: {{token}}" }, "config": { "unable-read-saved-config": "Unable to read saved config", diff --git a/www/js/App.tsx b/www/js/App.tsx index a35987c31..328e7ab29 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -16,12 +16,15 @@ import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; // import { getUserCustomLabels } from './services/commHelper'; import AlertBar from './components/AlertBar'; import Main from './Main'; +import { joinWithTokenOrUrl } from './config/dynamicConfig'; +import { addStatReading } from './plugin/clientStats'; export const AppContext = createContext({}); const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose']; type CustomLabelMap = { [k: string]: string[]; }; +type OnboardingJoinMethod = 'scan' | 'paste' | 'textbox' | 'external'; const App = () => { // will remain null while the onboarding state is still being determined @@ -36,6 +39,17 @@ const App = () => { refreshOnboardingState(); }, []); + // handleOpenURL function must be provided globally for cordova-plugin-customurlscheme + // https://www.npmjs.com/package/cordova-plugin-customurlscheme + window['handleOpenURL'] = async (url: string, joinMethod: OnboardingJoinMethod = 'external') => { + const configUpdated = await joinWithTokenOrUrl(url); + addStatReading('onboard', { configUpdated, joinMethod }); + if (configUpdated) { + refreshOnboardingState(); + } + return configUpdated; + }; + useEffect(() => { if (!appConfig) return; setServerConnSettings(appConfig).then(() => { @@ -49,6 +63,7 @@ const App = () => { const appContextValue = { appConfig, + handleOpenURL: window['handleOpenURL'], onboardingState, setOnboardingState, refreshOnboardingState, diff --git a/www/js/components/AlertBar.tsx b/www/js/components/AlertBar.tsx index 8b1b39fcf..6302bd795 100644 --- a/www/js/components/AlertBar.tsx +++ b/www/js/components/AlertBar.tsx @@ -10,6 +10,7 @@ type AlertMessage = { msgKey?: ParseKeys<'translation'>; text?: string; duration?: number; + style?: object; }; // public static AlertManager that can add messages from a global context @@ -45,6 +46,7 @@ const AlertBar = () => { visible={true} onDismiss={onDismissSnackBar} duration={messages[0].duration} + style={messages[0].style} action={{ label: t('join.close'), onPress: onDismissSnackBar, diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index c8547eaf8..c2db81734 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -5,6 +5,9 @@ we can remove this wrapper and just use the QRCode component directly */ import React from 'react'; import QRCode from 'react-qr-code'; import { logDebug, logWarn } from '../plugin/logger'; +import packageJsonBuild from '../../../package.cordovabuild.json'; + +const URL_SCHEME = packageJsonBuild.cordova.plugins['cordova-plugin-customurlscheme'].URL_SCHEME; export function shareQR(message) { /*code adapted from demo of react-qr-code*/ @@ -45,7 +48,7 @@ export function shareQR(message) { const QrCode = ({ value, ...rest }) => { let hasLink = value.toString().includes('//'); if (!hasLink) { - value = 'emission://login_token?token=' + value; + value = `${URL_SCHEME}://login_token?token=${value}`; } return ( diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 361b0d38c..bf978bc1c 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -3,6 +3,12 @@ import { displayError, logDebug, logWarn } from '../plugin/logger'; import { fetchUrlCached } from '../services/commHelper'; import { storageClear, storageGet, storageSet } from '../plugin/storage'; import { AppConfig } from '../types/appConfigTypes'; +import { + getStudyNameFromToken, + getStudyNameFromUrl, + getSubgroupFromToken, + getTokenFromUrl, +} from './opcode'; export const CONFIG_PHONE_UI = 'config/app_ui_config'; export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; @@ -16,36 +22,15 @@ export const _test_resetPromisedConfig = () => { _promisedConfig = undefined; }; -/** - * @param connectUrl The URL endpoint specified in the config - * @returns The study name (like 'stage' or whatever precedes 'openpath' in the URL), - * or undefined if it can't be determined - */ -function _getStudyName(connectUrl: `https://${string}`) { - const orig_host = new URL(connectUrl).hostname; - const first_domain = orig_host.split('.')[0]; - if (first_domain == 'openpath-stage') { - return 'stage'; - } - const openpath_index = first_domain.search('-openpath'); - if (openpath_index == -1) { - return undefined; - } - const study_name = first_domain.substr(0, openpath_index); - return study_name; -} - /** * @param config The app config which might be missing 'name' * @returns Shallow copy of the app config with 'name' filled in if it was missing */ function _fillStudyName(config: Partial): AppConfig { if (config.name) return config as AppConfig; - if (config.server) { - return { ...config, name: _getStudyName(config.server.connectUrl) } as AppConfig; - } else { - return { ...config, name: 'dev' } as AppConfig; - } + const url = config.server && new URL(config.server.connectUrl); + const name = url ? getStudyNameFromUrl(url) : 'dev'; + return { ...config, name } as AppConfig; } /** @@ -161,74 +146,6 @@ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { } } -/* - * We want to support both old style and new style tokens. - * Theoretically, we don't need anything from this except the study - * name, but we should re-validate the token for extra robustness. - * The control flow here is a bit tricky, though. - * - we need to first get the study name - * - then we need to retrieve the study config - * - then we need to re-validate the token against the study config, - * and the subgroups in the study config, in particular. - * - * So let's support two separate functions here - extractStudyName and extractSubgroup - */ -function extractStudyName(token: string): string { - const tokenParts = token.split('_'); - if (tokenParts.length < 3 || tokenParts.some((part) => part == '')) { - // all tokens must have at least nrelop_[studyname]_[usercode] - // and neither [studyname] nor [usercode] can be blank - throw new Error(i18next.t('config.not-enough-parts-old-style', { token: token })); - } - if (tokenParts[0] != 'nrelop') { - throw new Error(i18next.t('config.no-nrelop-start', { token: token })); - } - return tokenParts[1]; -} - -function extractSubgroup(token: string, config: AppConfig): string | undefined { - if (config.opcode) { - // new style study, expects token with sub-group - const tokenParts = token.split('_'); - if (tokenParts.length <= 3) { - // no subpart defined - throw new Error(i18next.t('config.not-enough-parts', { token: token })); - } - if (config.opcode.subgroups) { - if (config.opcode.subgroups.indexOf(tokenParts[2]) == -1) { - // subpart not in config list - throw new Error( - i18next.t('config.invalid-subgroup', { - token: token, - subgroup: tokenParts[2], - config_subgroups: config.opcode.subgroups, - }), - ); - } else { - logDebug('subgroup ' + tokenParts[2] + ' found in list ' + config.opcode.subgroups); - return tokenParts[2]; - } - } else { - if (tokenParts[2] != 'default') { - // subpart not in config list - throw new Error(i18next.t('config.invalid-subgroup-no-default', { token: token })); - } else { - logDebug("no subgroups in config, 'default' subgroup found in token "); - return tokenParts[2]; - } - } - } else { - /* old style study, expect token without subgroup - * nothing further to validate at this point - * only validation required is `nrelop_` and valid study name - * first is already handled in extractStudyName, second is handled - * by default since download will fail if it is invalid - */ - logDebug('Old-style study, expecting token without a subgroup...'); - return undefined; - } -} - /** * @description Download and load a new config from the server if it is a different version * @param newToken The new token, which includes parts for the study label, subgroup, and user @@ -236,7 +153,7 @@ function extractSubgroup(token: string, config: AppConfig): string | undefined { * @returns boolean representing whether the config was updated or not */ export function loadNewConfig(newToken: string, existingVersion?: number): Promise { - const newStudyLabel = extractStudyName(newToken); + const newStudyLabel = getStudyNameFromToken(newToken); return readConfigFromServer(newStudyLabel) .then((downloadedConfig) => { if (downloadedConfig.version == existingVersion) { @@ -245,7 +162,7 @@ export function loadNewConfig(newToken: string, existingVersion?: number): Promi } // we want to validate before saving because we don't want to save // an invalid configuration - const subgroup = extractSubgroup(newToken, downloadedConfig); + const subgroup = getSubgroupFromToken(newToken, downloadedConfig); const toSaveConfig = { ...downloadedConfig, joined: { opcode: newToken, study_name: newStudyLabel, subgroup: subgroup }, @@ -267,26 +184,28 @@ export function loadNewConfig(newToken: string, existingVersion?: number): Promi }) .catch((storeError) => { displayError(storeError, i18next.t('config.unable-to-store-config')); - return Promise.reject(storeError); + return Promise.resolve(false); }); }) .catch((fetchErr) => { displayError(fetchErr, i18next.t('config.unable-download-config')); - return Promise.reject(fetchErr); + return Promise.resolve(false); }); } // exported wrapper around loadNewConfig that includes error handling -export function initByUser(urlComponents: { token: string }) { - const { token } = urlComponents; +export async function joinWithTokenOrUrl(tokenOrUrl: string) { try { - return loadNewConfig(token).catch((fetchErr) => { - displayError(fetchErr, i18next.t('config.unable-download-config')); - return Promise.reject(fetchErr); - }); - } catch (error) { - displayError(error, i18next.t('config.invalid-opcode-format')); - return Promise.reject(error); + const token = tokenOrUrl.includes('://') ? getTokenFromUrl(tokenOrUrl) : tokenOrUrl; + try { + return await loadNewConfig(token); + } catch (err) { + displayError(err, i18next.t('config.invalid-opcode-format')); + return false; + } + } catch (err) { + displayError(err, 'Error parsing token or URL: ' + tokenOrUrl); + return false; } } diff --git a/www/js/config/opcode.ts b/www/js/config/opcode.ts new file mode 100644 index 000000000..429f03d16 --- /dev/null +++ b/www/js/config/opcode.ts @@ -0,0 +1,141 @@ +import i18next from 'i18next'; +import { logDebug } from '../plugin/logger'; +import AppConfig from '../types/appConfigTypes'; + +/** + * Adapted from https://stackoverflow.com/a/63363662/4040267 + * made available under a CC BY-SA 4.0 license + */ +function generateRandomString(length: number) { + const randomInts = window.crypto.getRandomValues(new Uint8Array(length * 2)); + const randomChars = Array.from(randomInts).map((b) => String.fromCharCode(b)); + const randomString = randomChars.join(''); + const validRandomString = window.btoa(randomString).replace(/[+/]/g, ''); + const truncatedRandomString = validRandomString.substring(0, length); + return truncatedRandomString; +} + +/* + * We want to support both old style and new style tokens. + * Theoretically, we don't need anything from this except the study + * name, but we should re-validate the token for extra robustness. + * The control flow here is a bit tricky, though. + * - we need to first get the study name + * - then we need to retrieve the study config + * - then we need to re-validate the token against the study config, + * and the subgroups in the study config, in particular. + * + * So let's support two separate functions here - getStudyNameFromToken and getSubgroupFromToken + */ +export function getStudyNameFromToken(token: string): string { + const tokenParts = token.split('_'); + if (tokenParts.length < 3 || tokenParts.some((part) => part == '')) { + // all tokens must have at least nrelop_[studyname]_[usercode] + // and neither [studyname] nor [usercode] can be blank + throw new Error(i18next.t('config.not-enough-parts-old-style', { token: token })); + } + if (tokenParts[0] != 'nrelop') { + throw new Error(i18next.t('config.no-nrelop-start', { token: token })); + } + return tokenParts[1]; +} + +export function getSubgroupFromToken(token: string, config: AppConfig): string | undefined { + if (config.opcode) { + // new style study, expects token with sub-group + const tokenParts = token.split('_'); + if (tokenParts.length <= 3) { + // no subpart defined + throw new Error(i18next.t('config.not-enough-parts', { token: token })); + } + if (config.opcode.subgroups) { + if (config.opcode.subgroups.indexOf(tokenParts[2]) == -1) { + // subpart not in config list + throw new Error( + i18next.t('config.invalid-subgroup', { + token: token, + subgroup: tokenParts[2], + config_subgroups: config.opcode.subgroups, + }), + ); + } else { + logDebug('subgroup ' + tokenParts[2] + ' found in list ' + config.opcode.subgroups); + return tokenParts[2]; + } + } else { + if (tokenParts[2] != 'default') { + // subpart not in config list + throw new Error(i18next.t('config.invalid-subgroup-no-default', { token: token })); + } else { + logDebug("no subgroups in config, 'default' subgroup found in token "); + return tokenParts[2]; + } + } + } else { + /* old style study, expect token without subgroup + * nothing further to validate at this point + * only validation required is `nrelop_` and valid study name + * first is already handled in getStudyNameFromToken, second is handled + * by default since download will fail if it is invalid + */ + logDebug('Old-style study, expecting token without a subgroup...'); + return undefined; + } +} + +/** + * @returns The study name for a URL, which is: + * - the value of the 'study_config' query parameter if present, + * - the first part of the hostname before '-openpath' if present, + * - 'stage' if the first part of the hostname is 'openpath-stage', + * - undefined if it can't be determined + * @example getStudyNameFromUrl(new URL('https://openpath-stage.nrel.gov/join/')) => 'stage' + * @example getStudyNameFromUrl(new URL('https://open-access-openpath.nrel.gov/join/')) => 'open-access' + * @example getStudyNameFromUrl(new URL('https://nrel-commute-openpath.nrel.gov/api/')) => 'nrel-commute' + * @example getStudyNameFromUrl(new URL('http://localhost:3274/?study_config=foo')) => 'foo' + */ +export function getStudyNameFromUrl(url) { + const studyConfigParam = url.searchParams.get('study_config'); + if (studyConfigParam) return studyConfigParam; + const firstDomain = url.hostname.split('.')[0]; + if (firstDomain == 'openpath-stage') return 'stage'; + const openpathSuffixIndex = firstDomain.indexOf('-openpath'); + if (openpathSuffixIndex == -1) return undefined; + return firstDomain.substring(0, openpathSuffixIndex); +} + +/** + * @example generateOpcodeFromUrl(new URL('https://open-access-openpath.nrel.gov/join/')) => nrelop_open-access_default_randomLongStringWith32Characters + * @example generateOpcodeFromUrl(new URL('https://open-access-openpath.nrel.gov/join/?sub_group=foo')) => nrelop_open-access_foo_randomLongStringWith32Characters + */ +function generateOpcodeFromUrl(url: URL) { + const studyName = getStudyNameFromUrl(url); + const subgroup = url.searchParams.get('sub_group') || 'default'; + const randomString = generateRandomString(32); + return url.searchParams.get('tester') == 'true' + ? `nrelop_${studyName}_${subgroup}_test_${randomString}` + : `nrelop_${studyName}_${subgroup}_${randomString}`; +} + +/** + * @description If the URL has a path of 'login_token', returns the token from the URL. If the URL has a path of 'join', generates a token and returns it. + * @example getTokenFromUrl('https://open-access-openpath.nrel.gov/join/') => nrelop_open-access_default_randomLongStringWith32Characters + * @example getTokenFromUrl('emission://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random + * @example getTokenFromUrl('nrelopenpath://login_token?token=nrelop_study_subgroup_random') => nrelop_study_subgroup_random + */ +export function getTokenFromUrl(url: string) { + const parsedUrl = new URL(url); + const path = parsedUrl.pathname.replace(/\//g, '') || parsedUrl.hostname; + if (path == 'join') { + const token = generateOpcodeFromUrl(parsedUrl); + logDebug(`getTokenFromUrl: found 'join' path in URL, using generated token ${token}`); + return token; + } else if (path == 'login_token') { + const token = parsedUrl.searchParams.get('token'); + if (!token) throw new Error(`URL ${url} had path 'login_token' but no token param`); + logDebug(`getTokenFromUrl: found 'login_token' path in URL, using token ${token}`); + return token; + } else { + throw new Error(`URL ${url} had path ${path}, expected 'join' or 'login_token'`); + } +} diff --git a/www/js/control/PopOpCode.tsx b/www/js/control/PopOpCode.tsx index 3687d513b..cced420db 100644 --- a/www/js/control/PopOpCode.tsx +++ b/www/js/control/PopOpCode.tsx @@ -6,13 +6,10 @@ import QrCode from '../components/QrCode'; import { AlertManager } from '../components/AlertBar'; import { settingStyles } from './ProfileSettings'; -const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { +const PopOpCode = ({ visibilityValue, token, action, setVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const opcodeList = tokenURL.split('='); - const opcode = opcodeList[opcodeList.length - 1]; - function copyText(textToCopy) { navigator.clipboard.writeText(textToCopy).then(() => { AlertManager.addMessage({ msgKey: 'Copied to clipboard!' }); @@ -22,13 +19,7 @@ const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { let copyButton; if (window['cordova'].platformId == 'ios') { copyButton = ( - { - copyText(opcode); - }} - style={styles.button} - /> + copyText(token)} style={styles.button} /> ); } @@ -41,8 +32,8 @@ const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { {t('general-settings.qrcode')} {t('general-settings.qrcode-share-title')} - - {opcode} + + {token} action()} style={styles.button} /> diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index 41029d4ee..02b1a25ab 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -579,7 +579,7 @@ const ProfileSettings = () => { shareQR(authSettings.opcode)}> {/* {view privacy} */} diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 87a0a3778..2698df5d4 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, useEffect } from 'react'; +import React, { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { View, @@ -22,11 +22,11 @@ import { useTheme, } from 'react-native-paper'; import color from 'color'; -import { initByUser } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { displayError, logDebug } from '../plugin/logger'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; import { onboardingStyles } from './OnboardingStack'; import { AlertManager } from '../components/AlertBar'; +import { addStatReading } from '../plugin/clientStats'; let barcodeScannerIsOpen = false; @@ -34,45 +34,25 @@ const WelcomePage = () => { const { t } = useTranslation(); const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); - const context = useContext(AppContext); - const { refreshOnboardingState } = context; + const { handleOpenURL } = useContext(AppContext); const [pasteModalVis, setPasteModalVis] = useState(false); const [infoPopupVis, setInfoPopupVis] = useState(false); const [existingToken, setExistingToken] = useState(''); - function getCode(result) { - let url = new window.URL(result.text); - let notCancelled = result.cancelled == false; - let isQR = result.format == 'QR_CODE'; - let hasPrefix = url.protocol == 'emission:'; - let hasToken = url.searchParams.has('token'); - let code = url.searchParams.get('token'); - - logDebug(`QR code ${result.text} checks: - cancel, format, prefix, params, code: - ${notCancelled}, ${isQR}, ${hasPrefix}, ${hasToken}, ${code}`); - - if (notCancelled && isQR && hasPrefix && hasToken) { - return code; - } else { - return false; - } - } - function scanCode() { if (barcodeScannerIsOpen) return; barcodeScannerIsOpen = true; + addStatReading('open_qr_scanner'); window['cordova'].plugins.barcodeScanner.scan( (result) => { barcodeScannerIsOpen = false; logDebug('scanCode: scanned ' + JSON.stringify(result)); - let code = getCode(result); - if (code != false) { - logDebug('scanCode: found code ' + code); - loginWithToken(code); - } else { - displayError(result.text, 'invalid study reference'); + if (result.cancelled) return; + if (!result?.text || result.format != 'QR_CODE') { + AlertManager.addMessage({ text: 'No QR code found in scan. Please try again.' }); + return; } + handleOpenURL(result.text, 'scan'); }, (error) => { barcodeScannerIsOpen = false; @@ -81,18 +61,19 @@ const WelcomePage = () => { ); } - function loginWithToken(token) { - initByUser({ token }) - .then((configUpdated) => { - if (configUpdated) { - setPasteModalVis(false); - refreshOnboardingState(); + function pasteCode() { + window['cordova'].plugins.clipboard.paste((clipboardContent: string) => { + addStatReading('paste_token'); + try { + if (!clipboardContent?.startsWith('nrelop_') && !clipboardContent?.includes('://')) { + throw new Error('Clipboard content is not a valid token or URL'); } - }) - .catch((err) => { - displayError(err, 'Error logging in with token'); - setExistingToken(''); - }); + handleOpenURL(clipboardContent, 'paste'); + } catch (e) { + logWarn(`Tried using clipboard content ${clipboardContent}: ${e}`); + setPasteModalVis(true); + } + }); } return ( @@ -136,7 +117,7 @@ const WelcomePage = () => { - setPasteModalVis(true)} icon="content-paste"> + {t('join.paste-code')} @@ -157,19 +138,26 @@ const WelcomePage = () => { contentStyle={{ fontFamily: 'monospace' }} /> - - + setInfoPopupVis(false)}> - setInfoPopupVis(false)}> + setInfoPopupVis(false)} + style={{ maxHeight: '80%' }}> {t('join.about-app-title', { appName: t('join.app-name') })} - - + + {t('join.about-app-para-1')} {t('join.about-app-para-2')} {t('join.about-app-para-3')} @@ -177,8 +165,8 @@ const WelcomePage = () => { - {t('join.all-green-status')} - {t('join.dont-force-kill')} - {t('join.background-restrictions')} - - + + diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 54fd0f1c2..6ad5f1851 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -4,6 +4,7 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logDebug } from '../plugin/logger'; import { EVENTS, publish } from '../customEventHandler'; import { readConsentState, isConsented } from '../splash/startprefs'; +import { addStatReading } from '../plugin/clientStats'; export const INTRO_DONE_KEY = 'intro_done'; @@ -62,13 +63,18 @@ export function getPendingOnboardingState(): Promise { route = OnboardingRoute.SURVEY; } + const opcode = config?.joined?.opcode; + logDebug(`pending onboarding state is ${route}; isIntroDone = ${isIntroDone}; config = ${config}; isConsented = ${isConsented}; - saveQrDone = ${saveQrDone}`); + saveQrDone = ${saveQrDone}; + opcode = ${opcode}`); + + addStatReading('onboarding_state', { route, opcode }); - return { route, opcode: config?.joined?.opcode }; + return { route, opcode }; }, ); } diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index 7c512f2c2..2487abdff 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -4,15 +4,25 @@ const CLIENT_TIME = 'stats/client_time'; const CLIENT_ERROR = 'stats/client_error'; type StatKey = + // app-wide interaction stats | 'app_state_change' | 'nav_tab_change' + | 'open_notification' + // onboarding interaction stats + | 'onboard' // { configUpdated (true if success, onboarding began), joinMethod (scan if in-app QR scan, paste if in-app paste, textbox if manually entered, external if launched from browser or external QR scanner)} + | 'onboarding_state' // { route (current OnoardingRoute enum value), opcode} + | 'open_qr_scanner' // user opened in-app qr scanner + | 'paste_token' // user clicked 'paste code' + // 'label' interaction stats | 'view_trip_details' | 'multilabel_open' | 'multilabel_choose' + // 'control' interaction stats | 'set_reminder_prefs' | 'force_sync' - | 'open_notification' + // unexpected states for debugging | 'missing_keys' + // errors | 'ui_error'; let appVersion;