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

Auto add authorities #587

Merged
merged 33 commits into from
Apr 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
478eb47
Add HCAService with test suite
Patrick-Erichsen Apr 19, 2020
1ee9ba9
Add tests
Patrick-Erichsen Apr 19, 2020
fd1c794
Prompt for new HCAs regardless of saved count
Patrick-Erichsen Apr 19, 2020
240ad8a
Convert to push notification
Patrick-Erichsen Apr 20, 2020
396356f
Fix merge conflicts
Patrick-Erichsen Apr 20, 2020
a4a9ad0
Tweak filter text and add localization
Apr 20, 2020
108237e
Fix behavior and tweak UI
Apr 20, 2020
93779ff
Fix typo
Patrick-Erichsen Apr 20, 2020
9ab37cb
Merge branch 'develop' into auto-add-authorities
Patrick-Erichsen Apr 20, 2020
4be0b35
Fix merge conflicts
Patrick-Erichsen Apr 21, 2020
879f9b8
Merge branch 'develop' into auto-add-authorities
Patrick-Erichsen Apr 21, 2020
661c7b6
Fix merge conflicts
Patrick-Erichsen Apr 21, 2020
f830c0f
Merge branch 'develop' into auto-add-authorities
Patrick-Erichsen Apr 21, 2020
2911ff8
Fix merge conflicts
Patrick-Erichsen Apr 21, 2020
2be0ef3
Add HCA Subcsription step to onboarding
Patrick-Erichsen Apr 22, 2020
6c0e84a
Proper onboarding flow with android notification skip
Patrick-Erichsen Apr 23, 2020
48f463f
Pull in checkbox component
Patrick-Erichsen Apr 23, 2020
febccc8
Pull strings into language file
Patrick-Erichsen Apr 23, 2020
2c92f12
Auto subscription CSS
Patrick-Erichsen Apr 23, 2020
c5cf867
Remove default export and add use to Typography elems
Patrick-Erichsen Apr 23, 2020
624d23f
Merge develop
Patrick-Erichsen Apr 23, 2020
bc45ec6
remove console logs
Patrick-Erichsen Apr 23, 2020
9fd2660
Merge develop
Patrick-Erichsen Apr 24, 2020
6ddf362
Begin cleanup on e2e tests
Patrick-Erichsen Apr 24, 2020
ff4f26e
Merge develop
Patrick-Erichsen Apr 24, 2020
867e3d9
Incorporate PR review feedback
Patrick-Erichsen Apr 25, 2020
388f943
Fix e2e tests
Patrick-Erichsen Apr 26, 2020
019c3d4
Merge branch 'develop' into auto-add-authorities
Patrick-Erichsen Apr 26, 2020
f67552c
Language string updates
Patrick-Erichsen Apr 26, 2020
800fde8
Merge branch 'auto-add-authorities' of github.com:Patrick-Erichsen/pr…
Patrick-Erichsen Apr 26, 2020
d9a25d8
Fix unit tests
Patrick-Erichsen Apr 26, 2020
5b7dea0
Merge develop
Patrick-Erichsen Apr 27, 2020
6354db8
Make subheader text wider
Patrick-Erichsen Apr 27, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions __mocks__/react-native-push-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export default {
onNotification: jest.fn(),
addEventListener: jest.fn(),
requestPermissions: jest.fn(),
localNotification: jest.fn(),
};
1 change: 0 additions & 1 deletion app/Entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class Entry extends Component {
componentDidMount() {
GetStoreData('ONBOARDING_DONE')
.then(onboardingDone => {
console.log(onboardingDone);
this.setState({
initialRouteName: onboardingDone,
});
Expand Down
1 change: 1 addition & 0 deletions app/constants/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export const DEBUG_MODE = 'DEBUG_MODE';
export const AUTHORITY_NEWS = 'AUTHORITY_NEWS';
export const LAST_CHECKED = 'LAST_CHECKED';
export const AUTHORITY_SOURCE_SETTINGS = 'AUTHORITY_SOURCE_SETTINGS';
export const ENABLE_HCA_AUTO_SUBSCRIPTION = 'ENABLE_HCA_AUTO_SUBSCRIPTION';
4 changes: 1 addition & 3 deletions app/helpers/General.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import AsyncStorage from '@react-native-community/async-storage';
import DocumentPicker from 'react-native-document-picker';

/**
* Get Data from Store

* Get data from store
*
* @param {string} key
* @param {boolean} isString
Expand All @@ -25,7 +24,6 @@ export async function GetStoreData(key, isString = true) {

/**
* Set data from store

*
* @param {string} key
* @param {object} item
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/Intersect.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,9 @@ export function checkIntersect() {
*/
async function asyncCheckIntersect() {
// first things first ... is it time to actually try the intersection?
let last_checked_ms = Number(await GetStoreData(LAST_CHECKED));
let lastCheckedMs = Number(await GetStoreData(LAST_CHECKED));
if (
last_checked_ms + MIN_CHECK_INTERSECT_INTERVAL * 60 * 1000 >
lastCheckedMs + MIN_CHECK_INTERSECT_INTERVAL * 60 * 1000 >
dayjs().valueOf()
)
return null;
Expand Down
22 changes: 19 additions & 3 deletions app/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@
"authorities_desc": "Choose trusted healthcare authorities in your area to obtain exposure data. Either select a name from the global registry, or enter the web address provided by an authority which has implemented Safe Paths.",
"authorities_input_placeholder": "Paste your URL here",
"authorities_no_sources": "No data source yet",
"authorities_new_in_area_title": "New Healthcare Authority",
"authorities_new_in_area_title_plural": "New Healthcare Authorities",
"authorities_new_in_area_msg": "Subscribe to {{count}} new trusted Healthcare Authority in your location",
"authorities_new_in_area_msg_plural": "Subscribe to {{count}} new trusted Healthcare Authorities in your location",
"authorities_new_subcription_title": "{{numAuthories}} New Subscription",
tstirrat marked this conversation as resolved.
Show resolved Hide resolved
"authorities_new_subcription_title_plural": "{{numAuthories}} New Subscriptions",
"authorities_new_subcription_msg": "You have been subscribed to {{count}} new authority in your location",
"authorities_new_subcription_msg_plural": "You have been subscribed to {{count}} new authorities in your location",
"authorities_removal_alert_cancel": "Cancel",
"authorities_removal_alert_desc": "Are you sure you want to remove this authority data source?",
"authorities_removal_alert_proceed": "Proceed",
"authorities_removal_alert_title": "Remove authority",
"authorities_removal_alert_title": "Remove Authority",
"authorities_title": "Trusted Sources",
"choose_provider_subtitle": "To be informed of exposures you will need to subscribe to a health authority.",
"choose_provider_title": "Choose health authority",
"auto_subscribe_checkbox": "Enable auto subscription",
"choose_provider_subtitle": "To be informed of exposures you will need to subscribe to a Health Authority.",
"choose_provider_title": "Choose Health Authority",
"commitment": "Commitment",
"commitment_para": "Safe Paths securely records and checks your interaction with people using your location. Your data will NEVER leave your phone without your consent.",
"default_news_site_name": "Safe Paths News",
"event_history_subtitle": "Understand your personal exposure based on information shared by health authorities.",
"event_history_title": "Exposure history",
"export_para_1": "If you test positive for COVID-19, please do your part by sharing your location history with local authorities.",
"export_para_2": "Location is shared as a simple list of times and places, no additional information.",
"filter_authorities_by_gps_history": "Filter by your locations",
"enter_authority_url": "Enter or paste URL",
"home_at_risk_header": "You May Be Exposed",
"home_at_risk_subsubtext": "This does not mean you are infected.",
"home_at_risk_subtext": "Based on your GPS history, it is possible you were in contact with or close to someone diagnosed with COVID-19.",
Expand All @@ -42,6 +53,7 @@
"launch_done_subheader": "You’re ready to roll. Remember, you can always update your preferences later.",
"launch_enable_location": "Enable Location",
"launch_enable_notif": "Enable Notifications",
"launch_enable_auto_subscription": "Enable auto subscription",
"launch_finish_set_up": "Finish Setup",
"launch_get_started": "Get Started",
"launch_location_access": "Location access",
Expand All @@ -51,6 +63,9 @@
"launch_notif_header": "Notifications will let you know if you cross paths with an infected person.",
"launch_notif_subheader": "We won't bother you except to share updates on your potential exposure risks.",
"launch_notification_access": "Allow notifications",
"launch_authority_header": "Healthcare Authorities will provide your device with the local data to know if you have crossed paths with an infected person",
"launch_authority_subheader": "Automatically subscribe to receive the latest updates from Healthcare Authorities in your area.",
"launch_authority_access": "Subscribe to nearby Health Authorities",
"launch_screen1_header": "The way back to normal starts here.",
"launch_screen2_header": "Get notified if you cross paths with someone later diagnosed for COVID-19.",
"launch_screen2_subheader": "Knowledge is power.",
Expand Down Expand Up @@ -79,6 +94,7 @@
"see_exposure_history": "See exposure history",
"settings_title": "Dashboard",
"share_location_data": "Share location data",
"skip_this_step": "Skip this step",
"team": "Team",
"team_para": "Our team is composed of a consortium of epidemiologists, engineers, data scientists, digital privacy evangelists, professors and researchers from reputable institutions, including: MIT, Harvard, The Mayo Clinic, TripleBlind, EyeNetra, Ernst & Young and Link Ventures.",
"terms_of_use": "Terms of use",
Expand Down
2 changes: 2 additions & 0 deletions app/services/BackgroundTaskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import BackgroundFetch from 'react-native-background-fetch';

import { INTERSECT_INTERVAL } from '../constants/history';
import { checkIntersect } from '../helpers/Intersect';
import { HCAService } from '../services/HCAService';

export function executeTask() {
checkIntersect();
HCAService.findNewAuthorities();
}

export default class BackgroundTaskServices {
Expand Down
255 changes: 255 additions & 0 deletions app/services/HCAService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import Yaml from 'js-yaml';
import PushNotification from 'react-native-push-notification';
import RNFetchBlob from 'rn-fetch-blob';

import { AUTHORITIES_LIST_URL } from '../constants/authorities';
import {
AUTHORITY_SOURCE_SETTINGS,
ENABLE_HCA_AUTO_SUBSCRIPTION,
} from '../constants/storage';
import { GetStoreData, SetStoreData } from '../helpers/General';
import languages from '../locales/languages';
import { LocationData } from './LocationService';

/**
* Singleton class to interact with health care authority data
*/
class HCAService {
/**
* Fetches the raw YAML file containing a list of all
* of the registered Health Care Authorities.
* Saves the response as a cached file for performance.
* @returns {void}
*/
async fetchAuthoritiesYaml() {
return await RNFetchBlob.config({
Patrick-Erichsen marked this conversation as resolved.
Show resolved Hide resolved
fileCache: true,
}).fetch('GET', AUTHORITIES_LIST_URL);
}

/**
* Fetches the list of all registed Health Care Authorities
* @returns {Array} List of health care authorities from the global registry
*/
async getAuthoritiesList() {
let authorities = [];

try {
const result = await this.fetchAuthoritiesYaml();
const list = await RNFetchBlob.fs.readFile(result.path(), 'utf8');
authorities = Yaml.safeLoad(list).Authorities;
} catch (err) {
console.error(err);
}

return authorities;
}

/**
* Get the list of Health Care Authorities that a user has saved
* @returns {Array} List of health care authorities from storage
*/
async getUserAuthorityList() {
return await GetStoreData(AUTHORITY_SOURCE_SETTINGS, false);
}

/**
* Takes an array of one or more authorities and adds it to the list
* in storage that the user is subscribed to.
* @param {newAuthorities} Array healthcare authoritiy objects
* @returns void
*/
async appendToAuthorityList(newAuthorities) {
const authorities = (await this.getUserAuthorityList()) || [];
await SetStoreData(AUTHORITY_SOURCE_SETTINGS, [
...authorities,
...newAuthorities,
]);
}

/**
* Checks if a user has saved any Health Care Authorities
*
* @returns {boolean}
*/
async hasSavedAuthorities() {
const authorities = await this.getUserAuthorityList();
return authorities && authorities.length > 0;
}

/**
* Alerts a user that there are new Healthcare Authorities in their region.
* Includes information on the number of Authorities in their current location.
*
* @param {count} number new authorities
* @returns {void}
*/
async pushAlertNewAuthoritesFromLoc(count) {
await PushNotification.localNotification({
title: languages.t('label.authorities_new_in_area_title', { count }),
message: languages.t('label.authorities_new_in_area_msg', { count }),
});
}

/**
* Alerts a user that they have been subscribed to new Healthcare Authorities
* in their region. Includes information on the number of Authorities in
* their current location.
*
* @param {count} number new authorities
* @returns {void}
*/
async pushAlertNewSubscribedAuthorities(count) {
await PushNotification.localNotification({
title: languages.t('label.authorities_new_subcription_title', { count }),
message: languages.t('label.authorities_new_subcription_msg', { count }),
});
}

/**
* Returns the `url` value for an authority
* @param Authority Healthcare Authority object
* @returns {string}
*/
getAuthorityUrl(authority) {
const authorityName = Object.keys(authority)[0];
const urlKey = authority[authorityName][0];
return urlKey && urlKey['url'];
}

/**
* Returns the `bounds` value for an authority
* @param {authority} Authority Healthcare Authority object
* @returns {{bounds: {ne: {latitude: number, longitude: number}}, {sw: {latitude: number, longitude: number}}}}
*/
getAuthorityBounds(authority) {
const authorityName = Object.keys(authority)[0];
const boundsKey = authority[authorityName][1];
return boundsKey && boundsKey['bounds'];
}

/**
* Checks if a given point is inside the bounds of the given authority
* @param {point} Object contains a `latitude` and `longitude` field
* @param {authority} Authority Healthcare Authority object
* @returns {boolean}
*/
isPointInAuthorityBounds(point, authority) {
const locHelper = new LocationData();
const bounds = this.getAuthorityBounds(authority);

return bounds && locHelper.isPointInBoundingBox(point, bounds);
}

/**
* Iterates over the full list of authorities and checks
* if there is any GPS point in the user's full 28-day location history
* that is within the bounds of the authority.
*
* @returns {[{authority_name: [{url: string}, {bounds: Object}]}]} List of health care authorities
*/
async getAuthoritiesFromUserLocHistory() {
const locData = await new LocationData().getLocationData();
const authorities = await this.getAuthoritiesList();

return authorities.filter(authority =>
locData.some(point => this.isPointInAuthorityBounds(point, authority)),
);
}

/**
* Gets the most recent location of the user and returns a list of
* all Healthcare Authorities whose bounds contain the user's current location,
* filtering out any Authorities the user has already subscribed to.
*
* @returns {[{authority_name: [{url: string}, {bounds: Object}]}]} list of Healthcare Authorities
*/
async getNewAuthoritiesInUserLoc() {
const mostRecentUserLoc = await new LocationData().getMostRecentUserLoc();
const authoritiesList = await this.getAuthoritiesList();
const userAuthorities = await this.getUserAuthorityList();

return authoritiesList.filter(
authority =>
this.isPointInAuthorityBounds(mostRecentUserLoc, authority) &&
!userAuthorities.includes(authority),
);
}

/**
* Subscribes a user to the provided list of authorities
* @param {newAuthorities} Array array of healthcare authorities
* @returns {void}
*/
async pushAlertNewSubscriptions(newAuthorities) {
await this.appendToAuthorityList(newAuthorities);
await this.pushAlertNewSubscribedAuthorities(newAuthorities.length);
}

/**
* Prompt a user to add a Health Authority if they are in the bounds
* of a healthcare authority that they have not yet subscribed to.
*
* This will trigger a push notification.
*
* @returns {void}
*/
async findNewAuthorities() {
const newAuthorities = await this.getNewAuthoritiesInUserLoc();

if (newAuthorities.length > 0) {
if (this.isAutosubscriptionEnabled()) {
await this.pushAlertNewSubscriptions(newAuthorities);
} else {
await this.pushAlertNewAuthoritesFromLoc(newAuthorities.length);
}
}
}

/**
* Checks if the user has explicitly approved or denied the auto subscribe
* feature. When pulling from async storage, if the key has not yet been set,
* the value will be null.
*
* @returns {boolean}
*/
async hasUserSetSubscription() {
const permission = await GetStoreData(ENABLE_HCA_AUTO_SUBSCRIPTION, true);

if (permission === null) {
return false;
} else {
return true;
}
}

/**
* Check if the user has opted in to auto subscribe to new Healthcare
* Authorities in their area.
*
* @returns {boolean}
*/
async isAutosubscriptionEnabled() {
return (await GetStoreData(ENABLE_HCA_AUTO_SUBSCRIPTION, true)) === 'true';
}

/**
* Enable auto subscription to new Healthcare Authorities in the user's area.
* @returns {void}
*/
async enableAutoSubscription() {
await SetStoreData(ENABLE_HCA_AUTO_SUBSCRIPTION, true);
}

/**
* Disable auto subscription to new Healthcare Authorities in the user's area.
* @returns {void}
*/
async disableAutoSubscription() {
await SetStoreData(ENABLE_HCA_AUTO_SUBSCRIPTION, false);
}
}

const singleton = new HCAService();

export { singleton as HCAService };