From 7bbe11249d7b5cc792585b2530d3d77457a33b20 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 11 Oct 2024 14:25:53 -0400 Subject: [PATCH 1/9] add opcode.ts, extracting functions from dynamicConfig and the join page https://github.com/e-mission/e-mission-docs/issues/1076 With the generation of OPcodes now occuring on the phone, several of the functions related to opcode generation will be moved from the join page to the phone. I put these in a separate file "opcode.ts" which will also pull in several of the functions from dynamicConfig that are related to validating and parsing opcodes While doing this, I adjusted the names of these functions to a unified convention, so let me describe where each one came from: generateRandomString: from join page's _getRandomString getStudyNameFromToken: from dynamicConfig's extractStudyName getSubgroupFromToken: from dynamicConfig's extractSubgroup getStudyNameFromUrl: from _getStudyName which was on both join page and dynamicConfig generate generateOpcodeFromUrl: from part of join page's validateAndGenerateToken (For reference, here is the current version of the join page that some of these functions are sourced from: https://github.com/e-mission/nrel-openpath-join-page/blob/main/frontend/index.html#L515) getTokenFromUrl and joinWithTokenOrUrl are new and help us handle 3 types of input: i) an actual token, ii) 'login_token' link that contains a token, and iii) 'join' link that gives parameters for a token to be generated on the phone --- www/js/config/dynamicConfig.ts | 102 ++-------------------- www/js/config/opcode.ts | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 95 deletions(-) create mode 100644 www/js/config/opcode.ts diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 361b0d38c..1c557be96 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -3,6 +3,7 @@ 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 } from './opcode'; export const CONFIG_PHONE_UI = 'config/app_ui_config'; export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; @@ -16,36 +17,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; } /** @@ -147,7 +127,7 @@ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { logDebug('Running in dev environment, checking for locally hosted config'); try { if (window['cordova'].platformId == 'android') { - downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; + // downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; } else { downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; } @@ -161,74 +141,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 +148,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 +157,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 }, diff --git a/www/js/config/opcode.ts b/www/js/config/opcode.ts new file mode 100644 index 000000000..63eb02c16 --- /dev/null +++ b/www/js/config/opcode.ts @@ -0,0 +1,153 @@ +import i18next from 'i18next'; +import { displayError, logDebug } from '../plugin/logger'; +import AppConfig from '../types/appConfigTypes'; +import { initByUser } from './dynamicConfig'; +import { AlertManager } from '../components/AlertBar'; + +/** + * 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 + */ +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'`); + } +} + +export async function joinWithTokenOrUrl(tokenOrUrl: string) { + const token = tokenOrUrl.includes('://') ? getTokenFromUrl(tokenOrUrl) : tokenOrUrl; + AlertManager.addMessage({ text: i18next.t('join.proceeding-with-token', { token }) }); + try { + return await initByUser({ token }); + } catch (err) { + displayError(err, 'Error logging in with token'); + } +} From 3aa3c38c48a3e9c953317d5dff74f1551c1714ba Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 11 Oct 2024 14:31:47 -0400 Subject: [PATCH 2/9] implement global 'handleOpenURL' function In the current version of the app, QR codes have to be scanned from within the app; scanning the QR code from the device's camera or any other external app will not successfully launch openpath. This used to work and was the method we used before we made a lot of changes to the onboarding flow. I found that the plugin relies on the existence of a global function (ie existing on the 'window' object). If we just supply this, the app can handle any emission:// link (or nrelopenpath:// link for the NREL version of the app) I'm also putting this function in the AppContext so it can be invoked in a "React"-friendly way downstream --- www/js/App.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/www/js/App.tsx b/www/js/App.tsx index a35987c31..f671617d1 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -16,6 +16,7 @@ import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; // import { getUserCustomLabels } from './services/commHelper'; import AlertBar from './components/AlertBar'; import Main from './Main'; +import { joinWithTokenOrUrl } from './config/opcode'; export const AppContext = createContext({}); const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose']; @@ -36,6 +37,16 @@ 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) => { + const configUpdated = await joinWithTokenOrUrl(url); + if (configUpdated) { + refreshOnboardingState(); + } + return configUpdated; + }; + useEffect(() => { if (!appConfig) return; setServerConnSettings(appConfig).then(() => { @@ -49,6 +60,7 @@ const App = () => { const appContextValue = { appConfig, + handleOpenURL: window['handleOpenURL'], onboardingState, setOnboardingState, refreshOnboardingState, From cb37e695c8c2262a82ad687509e9af5f83d2e47d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 14 Oct 2024 11:32:12 -0400 Subject: [PATCH 3/9] WelcomePage: handle tokens, 'join' / 'login_token' URLs, autopaste Now that reading URLs or tokens, generating a token (if applicable), and joining a study is handled by handleOpenURL (and the functions in opcode.ts it calls), we can use this on the WelcomePage. Scanning or entering an opcode or URL on the WelcomePage should do the exact same thing as handling an emission:// link that was opened from some external location. getCode and loginWithToken are not needed anymore, scanCode just needs to call handleOpenURL. I saw an opportunity to make the "paste" flow easier by providing a shortcut; when "Paste code" is clicked the app will read the user's clipboard and try to join using that. Only if that fails will the paste modal need to be opened, as was the previous UX. I used cordova-clipboard for this. --- package.cordovabuild.json | 2 + setup/setup_native.sh | 2 +- www/js/onboarding/WelcomePage.tsx | 87 +++++++++++++------------------ 3 files changed, 39 insertions(+), 52 deletions(-) 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/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 87a0a3778..63366979b 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,9 +22,8 @@ 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'; @@ -34,31 +33,11 @@ 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; @@ -66,13 +45,12 @@ const WelcomePage = () => { (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); }, (error) => { barcodeScannerIsOpen = false; @@ -81,18 +59,18 @@ const WelcomePage = () => { ); } - function loginWithToken(token) { - initByUser({ token }) - .then((configUpdated) => { - if (configUpdated) { - setPasteModalVis(false); - refreshOnboardingState(); + function pasteCode() { + window['cordova'].plugins.clipboard.paste((clipboardContent: string) => { + 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); + } catch (e) { + logWarn(`Tried using clipboard content ${clipboardContent}: ${e}`); + setPasteModalVis(true); + } + }); } return ( @@ -136,7 +114,7 @@ const WelcomePage = () => { - setPasteModalVis(true)} icon="content-paste"> + {t('join.paste-code')} @@ -157,19 +135,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 +162,8 @@ const WelcomePage = () => { - {t('join.all-green-status')} - {t('join.dont-force-kill')} - {t('join.background-restrictions')} - - + + From ac7da3828f2ecc0d544896c4ae3b4b7ee47d5409 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 14 Oct 2024 11:46:41 -0400 Subject: [PATCH 4/9] revert commenting out '10.0.2.2' downloadURL I had this change locally and did not mean to commit it --- www/js/config/dynamicConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 1c557be96..c1e21db54 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -127,7 +127,7 @@ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { logDebug('Running in dev environment, checking for locally hosted config'); try { if (window['cordova'].platformId == 'android') { - // downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; + downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; } else { downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; } From 16a9a87940886620efe45ead84b12fed69a7758b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 14 Oct 2024 11:53:01 -0400 Subject: [PATCH 5/9] better error handling in opcode.ts -catch error that could be thrown from getTokenFromUrl -pass return value of false on either of the catch blocks -only show "proceeding with OPcode ..." if result from initByUser was true -ability to pass style through to AlertBar; wordBreak: break-all so that long OPcodes will not be forced onto one line --- www/i18n/en.json | 3 ++- www/js/components/AlertBar.tsx | 2 ++ www/js/config/opcode.ts | 20 ++++++++++++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) 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/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/config/opcode.ts b/www/js/config/opcode.ts index 63eb02c16..a5483d5c2 100644 --- a/www/js/config/opcode.ts +++ b/www/js/config/opcode.ts @@ -143,11 +143,23 @@ function getTokenFromUrl(url: string) { } export async function joinWithTokenOrUrl(tokenOrUrl: string) { - const token = tokenOrUrl.includes('://') ? getTokenFromUrl(tokenOrUrl) : tokenOrUrl; - AlertManager.addMessage({ text: i18next.t('join.proceeding-with-token', { token }) }); try { - return await initByUser({ token }); + const token = tokenOrUrl.includes('://') ? getTokenFromUrl(tokenOrUrl) : tokenOrUrl; + try { + const result = await initByUser({ token }); + if (result) { + AlertManager.addMessage({ + text: i18next.t('join.proceeding-with-token', { token }), + style: { wordBreak: 'break-all' }, + }); + } + return result; + } catch (err) { + displayError(err, 'Error logging in with token: ' + token); + return false; + } } catch (err) { - displayError(err, 'Error logging in with token'); + displayError(err, 'Error parsing token or URL: ' + tokenOrUrl); + return false; } } From 8fbe0e4ac07b8a36b326c2b2e81a007bc5741877 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 16 Oct 2024 14:08:00 -0400 Subject: [PATCH 6/9] use URL_SCHEME from package.json; simplify PopOpCode.tsx Instead of hardcoding `emission://`, we can grab it from the package json. This way, the 'emission' string will only need to be changed in one place for other versions of the app. And since QrCode.tsx is able to handle either URLs or plain tokens, we can also simplify PopOpCode to just accept the plain token and pass this through to QrCode.tsx --- www/js/components/QrCode.tsx | 5 ++++- www/js/control/PopOpCode.tsx | 17 ++++------------- www/js/control/ProfileSettings.tsx | 2 +- 3 files changed, 9 insertions(+), 15 deletions(-) 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/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} */} From 3752e2c37b75682051355bb3eb26422c3fc6bce5 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 17 Oct 2024 12:15:36 -0400 Subject: [PATCH 7/9] combine opcode.joinWithTokenOrUrl with dynamicConfig.initByUser Looking at these 2 functions I realized they can be combined and error handling unified, so I combined these to make a new function using async/await syntax. I chose to put the new function in dynamicConfig because this avoids a circular dependency at the module level (which is allowed but not tidy) Now, `dynamicConfig.ts` imports from `opcode.ts` but `opcode.ts` no longer imports anything from `dynamicConfig.ts`. Updated the tests for dynamicConfig to validate that the correct error messages are shown. --- www/__tests__/dynamicConfig.test.ts | 80 ++++++++++++++++++++--------- www/js/config/dynamicConfig.ts | 31 ++++++----- www/js/config/opcode.ts | 26 +--------- 3 files changed, 76 insertions(+), 61 deletions(-) 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/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index c1e21db54..bf978bc1c 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -3,7 +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 } from './opcode'; +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'; @@ -179,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 index a5483d5c2..cb937f5af 100644 --- a/www/js/config/opcode.ts +++ b/www/js/config/opcode.ts @@ -1,8 +1,6 @@ import i18next from 'i18next'; -import { displayError, logDebug } from '../plugin/logger'; +import { logDebug } from '../plugin/logger'; import AppConfig from '../types/appConfigTypes'; -import { initByUser } from './dynamicConfig'; -import { AlertManager } from '../components/AlertBar'; /** * Adapted from https://stackoverflow.com/a/63363662/4040267 @@ -141,25 +139,3 @@ function getTokenFromUrl(url: string) { throw new Error(`URL ${url} had path ${path}, expected 'join' or 'login_token'`); } } - -export async function joinWithTokenOrUrl(tokenOrUrl: string) { - try { - const token = tokenOrUrl.includes('://') ? getTokenFromUrl(tokenOrUrl) : tokenOrUrl; - try { - const result = await initByUser({ token }); - if (result) { - AlertManager.addMessage({ - text: i18next.t('join.proceeding-with-token', { token }), - style: { wordBreak: 'break-all' }, - }); - } - return result; - } catch (err) { - displayError(err, 'Error logging in with token: ' + token); - return false; - } - } catch (err) { - displayError(err, 'Error parsing token or URL: ' + tokenOrUrl); - return false; - } -} From ee85fb066f3be46daf62035f1fec18e927abf939 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 17 Oct 2024 12:20:55 -0400 Subject: [PATCH 8/9] add tests for opcode.ts Because joinWithTokenOrUrl in dynamicConfig.ts calls functions from opcode.ts and there are tests for that, most of opcode.ts is already covered. However, I am adding a few more dedicated tests to cover more scenarios. --- www/__tests__/opcode.test.ts | 81 ++++++++++++++++++++++++++++++++++++ www/js/config/opcode.ts | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 www/__tests__/opcode.test.ts 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/js/config/opcode.ts b/www/js/config/opcode.ts index cb937f5af..429f03d16 100644 --- a/www/js/config/opcode.ts +++ b/www/js/config/opcode.ts @@ -123,7 +123,7 @@ function generateOpcodeFromUrl(url: URL) { * @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 */ -function getTokenFromUrl(url: string) { +export function getTokenFromUrl(url: string) { const parsedUrl = new URL(url); const path = parsedUrl.pathname.replace(/\//g, '') || parsedUrl.hostname; if (path == 'join') { From 478815e4f77590562489c110d34e82fc5a463ee4 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 23 Oct 2024 14:16:32 -0400 Subject: [PATCH 9/9] add stats for onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have multiple ways to initiate onboarding: - scan QR in the app - paste from clipboard in the app - entering to a textbox in the app - launching a nrelopenpath:// link from an external app (browser, camera) We want to keep track of how often these methods are used, as well as how often they are successful. First I added the 'onboard' stat which represents an attempt to onboard. The reading has 'configUpdated' (to indicate whether the attempt was successful), and 'joinMethod' ('scan', 'paste', 'textbox', or 'external') I also added "open_qr_scanner" and "paste_token" events (no readings) because the user might open the QR scanner but not scan any QR codes. An 'onboard' stat would not be recorded, but we still want to know if the user clicked "Scan QR" Lastly, I added 'onboarding_state' as a more general stat for evaluating our onboarding process. We should be able to compare the timestamp at each reading to see how long users take at each stage of the onboarding process – as well as how long the entire onboarding process takes --- www/js/App.tsx | 7 +++++-- www/js/onboarding/WelcomePage.tsx | 9 ++++++--- www/js/onboarding/onboardingHelper.ts | 10 ++++++++-- www/js/plugin/clientStats.ts | 12 +++++++++++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/www/js/App.tsx b/www/js/App.tsx index f671617d1..328e7ab29 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -16,13 +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/opcode'; +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 @@ -39,8 +41,9 @@ const App = () => { // handleOpenURL function must be provided globally for cordova-plugin-customurlscheme // https://www.npmjs.com/package/cordova-plugin-customurlscheme - window['handleOpenURL'] = async (url: string) => { + window['handleOpenURL'] = async (url: string, joinMethod: OnboardingJoinMethod = 'external') => { const configUpdated = await joinWithTokenOrUrl(url); + addStatReading('onboard', { configUpdated, joinMethod }); if (configUpdated) { refreshOnboardingState(); } diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 63366979b..2698df5d4 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -26,6 +26,7 @@ import { AppContext } from '../App'; import { displayError, logDebug, logWarn } from '../plugin/logger'; import { onboardingStyles } from './OnboardingStack'; import { AlertManager } from '../components/AlertBar'; +import { addStatReading } from '../plugin/clientStats'; let barcodeScannerIsOpen = false; @@ -41,6 +42,7 @@ const WelcomePage = () => { function scanCode() { if (barcodeScannerIsOpen) return; barcodeScannerIsOpen = true; + addStatReading('open_qr_scanner'); window['cordova'].plugins.barcodeScanner.scan( (result) => { barcodeScannerIsOpen = false; @@ -50,7 +52,7 @@ const WelcomePage = () => { AlertManager.addMessage({ text: 'No QR code found in scan. Please try again.' }); return; } - handleOpenURL(result.text); + handleOpenURL(result.text, 'scan'); }, (error) => { barcodeScannerIsOpen = false; @@ -61,11 +63,12 @@ const WelcomePage = () => { 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'); } - handleOpenURL(clipboardContent); + handleOpenURL(clipboardContent, 'paste'); } catch (e) { logWarn(`Tried using clipboard content ${clipboardContent}: ${e}`); setPasteModalVis(true); @@ -138,7 +141,7 @@ const WelcomePage = () => {