Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support join URLs to generate OPcodes on phone, Fix URL scheme, "Paste" improvements #1187

Merged
merged 9 commits into from
Oct 23, 2024
2 changes: 2 additions & 0 deletions package.cordovabuild.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
},
"com.unarin.cordova.beacon": {},
"cordova-plugin-ionic-keyboard": {},
"cordova-clipboard": {},
"cordova-plugin-app-version": {},
"cordova-plugin-file": {},
"cordova-plugin-device": {},
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion setup/setup_native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand Down
80 changes: 56 additions & 24 deletions www/__tests__/dynamicConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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';
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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);
});
});
});
81 changes: 81 additions & 0 deletions www/__tests__/opcode.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
3 changes: 2 additions & 1 deletion www/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions www/js/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>({});
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
Expand All @@ -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(() => {
Expand All @@ -49,6 +63,7 @@ const App = () => {

const appContextValue = {
appConfig,
handleOpenURL: window['handleOpenURL'],
onboardingState,
setOnboardingState,
refreshOnboardingState,
Expand Down
2 changes: 2 additions & 0 deletions www/js/components/AlertBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion www/js/components/QrCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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*/
Expand Down Expand Up @@ -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 (
Expand Down
Loading
Loading