diff --git a/.github/workflows/android-automated-sdk-install.yml b/.github/workflows/android-automated-sdk-install.yml index f4275a98d..2ece9029f 100644 --- a/.github/workflows/android-automated-sdk-install.yml +++ b/.github/workflows/android-automated-sdk-install.yml @@ -6,10 +6,12 @@ on: push: paths: - 'setup/prereq_android_sdk_install.sh' + - 'setup/android_sdk_packages' - '.github/workflows/android-automated-sdk-install.yml' pull_request: paths: - 'setup/prereq_android_sdk_install.sh' + - 'setup/android_sdk_packages' - '.github/workflows/android-automated-sdk-install.yml' schedule: # * is a special character in YAML so you have to quote this string @@ -64,7 +66,6 @@ jobs: ls -al $ANDROID_SDK_ROOT if [ ! -d $ANDROID_SDK_ROOT/emulator ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/build-tools ]; then exit 1; fi - if [ ! -d $ANDROID_SDK_ROOT/patcher ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/extras ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/platforms ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/platform-tools ]; then exit 1; fi diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 25eb65317..ddc6b2ee0 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -22,20 +22,24 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-14 # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 + # Runs a single command using the runners shell + - name: Prints related environment variables so that we can know what to set + run: env | egrep "JAVA|PATH|ANDROID" + # Runs a single command using the runners shell - name: Print the java and gradle versions run: | echo "Default java version" java -version echo "Setting to Java 11 instead" - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 java -version echo "Checking gradle" which gradle @@ -49,13 +53,13 @@ jobs: - name: Setup the cordova environment shell: bash -l {0} run: | - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 bash setup/setup_android_native.sh - name: Check tool versions shell: bash -l {0} run: | - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 source setup/activate_native.sh echo "cordova version" npx cordova -version @@ -73,7 +77,7 @@ jobs: gradle -version echo "Let's rerun the activation" source setup/activate_native.sh - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 echo $PATH which gradle gradle --version diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index b0e94db22..dc1af47ac 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -27,8 +27,9 @@ jobs: npx jest - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/coverage-final.json flags: unit fail_ci_if_error: ${{ github.repository == 'e-mission/e-mission-phone' }} diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index ad0ce2f01..695ed02de 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -22,7 +22,7 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-14 # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/README.md b/README.md index 121684e0a..d75b62f6f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone Updating the UI only --- -[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) +[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). @@ -87,9 +87,9 @@ If you wish to connect to a different server, create your own config file accord Updating the e-mission-\* plugins or adding new plugins --- -[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) -[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) -[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml) +[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml?event-push) +[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml?event=push) +[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml?event=push) Pre-requisites --- diff --git a/config.cordovabuild.xml b/config.cordovabuild.xml index d3d562802..3401b9b9b 100644 --- a/config.cordovabuild.xml +++ b/config.cordovabuild.xml @@ -36,6 +36,8 @@ + + diff --git a/hooks/before_build/ios/ios_change_deployment.js b/hooks/before_build/ios/ios_change_deployment.js new file mode 100644 index 000000000..ad381162d --- /dev/null +++ b/hooks/before_build/ios/ios_change_deployment.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +function findFilePathsByFilename(directory, filename) { + const files = fs.readdirSync(directory); + const filePaths = []; + + for (const file of files) { + const filePath = path.join(directory, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory()) { + // Recursively search in subdirectories + const subdirectoryFilePaths = findFilePathsByFilename(filePath, filename); + filePaths.push(...subdirectoryFilePaths); + } else if (stats.isFile() && file === filename) { + // If the file matches the filename, add its path to the result + filePaths.push(filePath); + } + } + return filePaths; +} + + +const paths1 = findFilePathsByFilename('.', 'project.pbxproj'); +const paths2 = findFilePathsByFilename('.', 'Pods.xcodeproj'); +const paths = paths1.concat(paths2) + +console.log('Apply patch to', paths); + +for (let path of paths) { + let content = fs.readFileSync(path, { encoding: 'utf-8' }); + content = content.replace(/IPHONEOS_DEPLOYMENT_TARGET = [0-9]+.0;/g, 'IPHONEOS_DEPLOYMENT_TARGET = 13.0;'); + fs.writeFileSync(path, content); +} + +console.log('Done setting IPHONEOS_DEPLOYMENT_TARGET'); diff --git a/jest.config.js b/jest.config.js index 6a1dcd42b..73521d81e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,10 +12,15 @@ module.exports = { "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" }, transformIgnorePatterns: [ - "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?|e-mission-common)/)" ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleDirectories: ["node_modules", "src"], globals: {"__DEV__": false}, collectCoverage: true, + collectCoverageFrom: [ + "www/js/**/*.{ts,tsx,js,jsx}", + "!www/js/**/index.{ts,tsx,js,jsx}", + "!www/js/types/**/*.{ts,tsx,js,jsx}", + ], }; diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 0ca7717bd..c1317a782 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -26,7 +26,7 @@ "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", "babel-loader": "^9.1.2", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", @@ -121,13 +121,13 @@ "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.3", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.5", "cordova-plugin-em-opcodeauth": "git+https://github.com/e-mission/cordova-jwt-auth.git#v1.7.2", "cordova-plugin-em-server-communication": "git+https://github.com/e-mission/cordova-server-communication.git#v1.2.6", "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", "cordova-plugin-em-settings": "git+https://github.com/e-mission/cordova-connection-settings.git#v1.2.3", "cordova-plugin-em-unifiedlogger": "git+https://github.com/e-mission/cordova-unified-logger.git#v1.3.6", - "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.8", + "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.9", "cordova-plugin-email-composer": "git+https://github.com/katzer/cordova-plugin-email-composer.git#0.10.1", "cordova-plugin-file": "8.0.0", "cordova-plugin-inappbrowser": "5.0.0", @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.3.2", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", @@ -153,9 +153,9 @@ "npm": "^9.6.3", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", + "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", diff --git a/package.serve.json b/package.serve.json index bf39fd68d..b610d6121 100644 --- a/package.serve.json +++ b/package.serve.json @@ -26,7 +26,7 @@ "@ionic/cli": "6.20.8", "@testing-library/react-native": "^12.3.0", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", "babel-plugin-optional-require": "^0.3.1", @@ -40,6 +40,7 @@ "jest-environment-jsdom": "^29.7.0", "phonegap": "9.0.0+cordova.9.0.0", "process": "^0.11.10", + "react-test-renderer": "~18.2.0", "sass": "^1.62.1", "sass-loader": "^13.3.1", "style-loader": "^3.3.3", @@ -64,7 +65,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", - "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.3.2", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", @@ -77,9 +78,9 @@ "luxon": "^3.3.0", "npm": "^9.6.3", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", + "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 12657d589..6642b6ed4 100644 --- a/www/__tests__/confirmHelper.test.ts +++ b/www/__tests__/confirmHelper.test.ts @@ -45,6 +45,16 @@ const fakeDefaultLabelOptions = { }, }, }; +const fakeInputs = { + MODE: [ + { data: { label: 'walk', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'bike', start_ts: 1245, end_ts: 5678 } }, + ], + PURPOSE: [ + { data: { label: 'home', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'work', start_ts: 1245, end_ts: 5678 } }, + ], +}; jest.mock('../js/services/commHelper', () => ({ ...jest.requireActual('../js/services/commHelper'), @@ -62,8 +72,8 @@ describe('confirmHelper', () => { it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { const fakeLabelUserInput = { - MODE: fakeDefaultLabelOptions.MODE[1], - PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + MODE: fakeInputs.MODE[1], + PURPOSE: fakeInputs.PURPOSE[0], }; const labelInputDetails = labelInputDetailsForTrip( fakeLabelUserInput, @@ -74,8 +84,8 @@ describe('confirmHelper', () => { it('returns full labelInputDetails for a labelUserInput which has the mode of study', () => { const fakeLabelUserInput = { - MODE: fakeDefaultLabelOptions.MODE[0], // 'walk' is mode of study - PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + MODE: fakeInputs.MODE[0], // 'walk' is mode of study + PURPOSE: fakeInputs.PURPOSE[0], }; const labelInputDetails = labelInputDetailsForTrip( fakeLabelUserInput, diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 062951b35..9ea4d9b02 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,6 +1,6 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { unprocessedLabels, updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; +import { updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; import * as logger from '../js/plugin/logger'; import { EnketoUserInputEntry } from '../js/survey/enketo/enketoHelper'; import { @@ -376,9 +376,9 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { user_input: { trip_user_input: { data: { - name: 'TripConfirmSurvey', + name: 'MyCustomSurvey', version: 1, - xmlResponse: '', + xmlResponse: '', start_ts: 1000, end_ts: 3000, }, @@ -417,6 +417,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ], }, ] as any as TimelineEntry[]; + + // reset local unprocessed inputs to ensure MUTLILABEL inputs don't leak into ENKETO tests + beforeAll(async () => { + await updateLocalUnprocessedInputs({ start_ts: 1000, end_ts: 5000 }, fakeConfigEnketo); + }); + it('creates a map that has the processed responses and notes', () => { const [labelMap, notesMap] = mapInputsToTimelineEntries( timelineEntriesEnketo, @@ -424,8 +430,8 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ); expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, }); @@ -460,12 +466,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, trip2: { - SURVEY: { + TripConfirmSurvey: { data: { xmlResponse: '' }, }, }, diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index aafe13926..c1c130272 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -165,10 +165,10 @@ describe('unprocessedLabels, unprocessedNotes', () => { // update unprocessed inputs and check that the trip survey response shows up in unprocessedLabels await updateAllUnprocessedInputs({ start_ts: 4, end_ts: 6 }, mockTLH.mockConfigEnketo); - expect(unprocessedLabels['SURVEY'][0].data).toEqual(tripSurveyResponse); + expect(unprocessedLabels['TripConfirmSurvey'][0].data).toEqual(tripSurveyResponse); // the second response is ignored for now because we haven't enabled place_user_input yet // so the length is only 1 - expect(unprocessedLabels['SURVEY'].length).toEqual(1); + expect(unprocessedLabels['TripConfirmSurvey'].length).toEqual(1); }); it('has some trip- and place- level additions after they were just recorded', async () => { @@ -291,7 +291,7 @@ jest.mock('../js/services/unifiedDataLoader', () => ({ })); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(-1, -1, {} as any)).resolves.toEqual([]); + expect(readUnprocessedTrips(-1, -1, {} as any, {} as any)).resolves.toEqual([]); }); it('works when there are one or more unprocessed trips...', async () => { @@ -299,6 +299,7 @@ it('works when there are one or more unprocessed trips...', async () => { mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne, {} as any, + {} as any, ); expect(testValueOne.length).toEqual(1); expect(testValueOne[0]).toEqual( diff --git a/www/i18n/en.json b/www/i18n/en.json index 9a8b6bb61..2834219af 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -136,7 +136,6 @@ "choose-mode": "Mode", "choose-replaced-mode": "Replaces", "choose-purpose": "Purpose", - "choose-survey": "Add Trip Details", "select-mode-scroll": "Mode (scroll for more)", "select-replaced-mode-scroll": "Replaces (scroll for more)", "select-purpose-scroll": "Purpose (scroll for more)", diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 7dc1e190b..bbef214bd 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -20,14 +20,11 @@ import { isoDateRangeToTsRange, } from './diary/timelineHelper'; import { getPipelineRangeTs } from './services/commHelper'; -import { - getNotDeletedCandidates, - mapBleScansToTimelineEntries, - mapInputsToTimelineEntries, -} from './survey/inputMatcher'; +import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; import { publish } from './customEventHandler'; import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; import { VehicleIdentity } from './types/appConfigTypes'; +import { primarySectionForTrip } from './diary/diaryHelper'; // initial query range is the past 7 days, including today const today = DateTime.now().toISODate().substring(0, 10); @@ -71,7 +68,6 @@ export const useTimelineContext = (): ContextProps => { const [timelineIsLoading, setTimelineIsLoading] = useState('replace'); const [timelineLabelMap, setTimelineLabelMap] = useState(null); const [timelineNotesMap, setTimelineNotesMap] = useState(null); - const [timelineBleMap, setTimelineBleMap] = useState(null); const [refreshTime, setRefreshTime] = useState(null); // initialization, once the appConfig is loaded @@ -139,12 +135,6 @@ export const useTimelineContext = (): ContextProps => { ); setTimelineLabelMap(newTimelineLabelMap); setTimelineNotesMap(newTimelineNotesMap); - - if (appConfig.vehicle_identities?.length) { - const newTimelineBleMap = mapBleScansToTimelineEntries(allEntries, appConfig); - setTimelineBleMap(newTimelineBleMap); - } - publish('applyLabelTabFilters', { timelineMap, timelineLabelMap: newTimelineLabelMap, @@ -161,7 +151,7 @@ export const useTimelineContext = (): ContextProps => { unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); if (appConfig.vehicle_identities?.length) { await updateUnprocessedBleScans({ - start_ts: pipelineRange.start_ts, + start_ts: pipelineRange.end_ts, end_ts: Date.now() / 1000, }); logDebug(`Timeline: After updating unprocessedBleScans, @@ -259,6 +249,7 @@ export const useTimelineContext = (): ContextProps => { readUnprocessedPromise = readUnprocessedTrips( Math.max(pipelineRange.end_ts, startTs), endTs, + appConfig, lastProcessedTrip, ); } else { @@ -303,8 +294,8 @@ export const useTimelineContext = (): ContextProps => { * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, * or the label option from a user-given 'MODE' label, or undefined if neither exists. */ - const confirmedModeFor = (tlEntry: TimelineEntry) => - timelineBleMap?.[tlEntry._id.$oid] || labelFor(tlEntry, 'MODE'); + const confirmedModeFor = (tlEntry: CompositeTrip) => + primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE'); function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { const tlEntry = timelineMap?.get(oid); @@ -312,9 +303,9 @@ export const useTimelineContext = (): ContextProps => { return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); const nowTs = new Date().getTime() / 1000; // epoch seconds if (inputType == 'label') { - const newLabels = {}; - for (const [inputType, labelValue] of Object.entries(userInput)) { - newLabels[inputType] = { data: labelValue, metadata: nowTs }; + const newLabels: UserInputMap = {}; + for (const [inputType, labelValue] of Object.entries(userInput)) { + newLabels[inputType] = { data: labelValue, metadata: { write_ts: nowTs } as any }; } logDebug('Timeline: newLabels = ' + JSON.stringify(newLabels)); const newTimelineLabelMap: TimelineLabelMap = { @@ -369,13 +360,13 @@ export const useTimelineContext = (): ContextProps => { }; export type UserInputMap = { - /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input - value will have the raw 'xmlResponse' string */ - SURVEY?: EnketoUserInputEntry; -} & { - /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration - and will have the 'label' string but no 'xmlResponse' string */ + /* If keys are 'MODE', 'PURPOSE', 'REPLACED_MODE', this is the MULTILABEL configuration. + Values are entries that have a 'label' value in their 'data' */ [k in MultilabelKey]?: UserInputEntry; +} & { + /* Otherwise we are in the ENKETO configuration, and keys are names of surveys. + Values are entries that have an 'xmlResponse' value in their 'data' */ + [k: string]: EnketoUserInputEntry | undefined; }; export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index a4e5bda9e..b50bf5283 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -97,7 +97,6 @@ const BluetoothScanPage = ({ ...props }: any) => { in_range: status, }, })); - let { monitorResult: _, in_range: _, ...noResultDevice } = sampleBLEDevices[uuid]; window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( status ? 'REGION_ENTER' : 'REGION_EXIT', uuid, @@ -118,16 +117,6 @@ const BluetoothScanPage = ({ ...props }: any) => { rangeResult: result, }, })); - // we don't want to exclude monitorResult and rangeResult from the values - // that we save because they are the current or previous result, just - // in a different format - // https://stackoverflow.com/a/34710102 - let { - monitorResult: _, - rangeResult: _, - in_range: _, - ...noResultDevice - } = sampleBLEDevices[uuid]; let parsedResult = JSON.parse(result); parsedResult.beacons.forEach((beacon) => { window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index d9b9f3235..5843af3d2 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -92,11 +92,11 @@ function cacheResourcesFromConfig(config: AppConfig) { if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); - fetchUrlCached(survey['formPath']); + fetchUrlCached(survey['formPath'], { cache: 'reload' }); }); } if (config.label_options) { - fetchUrlCached(config.label_options); + fetchUrlCached(config.label_options, { cache: 'reload' }); } } diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 65526dc8a..f619fbf4e 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -76,7 +76,7 @@ const LabelTab = () => { const labels = labelMap[e._id.$oid]; for (let labelValue of Object.values(labels || [])) { logDebug(`LabelTab filtering: labelValue = ${JSON.stringify(labelValue)}`); - if (labelValue?.metadata?.write_ts > cutoffTs) { + if (labelValue?.metadata?.write_ts || 0 > cutoffTs) { logDebug('LabelTab filtering: entry has recent user input, keeping'); return true; } diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index f02797fff..12495742d 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -192,6 +192,15 @@ export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfi })); } +/** + * @param trip A composite trip object + * @return the primary section of the trip, i.e. the section with the greatest distance + */ +export function primarySectionForTrip(trip: CompositeTrip) { + if (!trip.sections?.length) return undefined; + return trip.sections.reduce((prev, curr) => (prev.distance > curr.distance ? prev : curr)); +} + export function getLocalTimeString(dt?: LocalDt) { if (!dt) return; const dateTime = DateTime.fromObject({ diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 094dd0c58..d5c1e507b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -17,12 +17,18 @@ import { BluetoothBleData, SectionData, CompositeTripLocation, + SectionSummary, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; -import { EnketoUserInputEntry, filterByNameAndVersion } from '../survey/enketo/enketoHelper'; +import { + EnketoUserInputEntry, + filterByNameAndVersion, + resolveSurveyButtonConfig, +} from '../survey/enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; import { Point, Feature } from 'geojson'; +import { ble_matching } from 'e-mission-common'; const cachedGeojsons: Map = new Map(); @@ -89,7 +95,8 @@ export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: bo } /* 'LABELS' are 1:1 - each trip or place has a single label for each label type - (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ + (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or the name of the survey + for ENKETO configuration) */ export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ export let unprocessedNotes: EnketoUserInputEntry[] = []; @@ -113,10 +120,14 @@ function updateUnprocessedInputs( const labelResults = comboResults.slice(0, labelsPromises.length); const notesResults = comboResults.slice(labelsPromises.length).flat(2); // fill in the unprocessedLabels object with the labels we just read + unprocessedLabels = {}; labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig); - unprocessedLabels['SURVEY'] = filtered as UserInputEntry[]; + const tripSurveys = resolveSurveyButtonConfig(appConfig, 'trip-label'); + tripSurveys.forEach((survey) => { + const filtered = filterByNameAndVersion(survey.surveyName, r, appConfig); + unprocessedLabels[survey.surveyName] = filtered as UserInputEntry[]; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } @@ -182,7 +193,7 @@ export async function updateUnprocessedBleScans(queryRange: TimestampRange) { endTs: queryRange.end_ts, }; const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then( + await getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then( (bleScans: BEMData[]) => { logDebug(`Read ${bleScans.length} BLE scans`); unprocessedBleScans = bleScans; @@ -307,10 +318,25 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({ second: currtime.second, }); +/* Compute a section summary, which is really simple for unprocessed trips because they are + always assumed to be unimodal. +/* maybe unify with eaum.get_section_summary on e-mission-server at some point */ +const getSectionSummaryForUnprocessed = (section: SectionData, modeProp): SectionSummary => { + const baseMode = section[modeProp] || 'UNKNOWN'; + return { + count: { [baseMode]: 1 }, + distance: { [baseMode]: section.distance }, + duration: { [baseMode]: section.duration }, + }; +}; + /** * @description Given an array of location points, creates an UnprocessedTrip object. */ -function points2UnprocessedTrip(locationPoints: Array>): UnprocessedTrip { +function points2UnprocessedTrip( + locationPoints: Array>, + appConfig: AppConfig, +): UnprocessedTrip { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; @@ -370,6 +396,12 @@ function points2UnprocessedTrip(locationPoints: Array> origin_key: 'UNPROCESSED_section', sensed_mode: 4, // MotionTypes.UNKNOWN (4) sensed_mode_str: 'UNKNOWN', + ble_sensed_mode: ble_matching.get_ble_sensed_vehicle_for_section( + unprocessedBleScans, + baseProps.start_ts, + baseProps.end_ts, + appConfig, + ), trip_id: { $oid: tripAndSectionId }, }; @@ -378,6 +410,9 @@ function points2UnprocessedTrip(locationPoints: Array> ...baseProps, _id: { $oid: tripAndSectionId }, additions: [], + ble_sensed_summary: getSectionSummaryForUnprocessed(singleSection, 'ble_sensed_mode'), + cleaned_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'), + inferred_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'), confidence_threshold: 0, expectation: { to_label: true }, inferred_labels: [], @@ -396,7 +431,10 @@ const tsEntrySort = (e1: BEMData, e2: BEMData): Promise { +function tripTransitions2UnprocessedTrip( + trip: Array, + appConfig: AppConfig, +): Promise { const tripStartTransition = trip[0]; const tripEndTransition = trip[1]; const tq = { @@ -438,7 +476,7 @@ function tripTransitions2UnprocessedTrip(trip: Array): Promise { logDebug(JSON.stringify(trip, null, 2)); }); - const tripFillPromises = tripsList.map(tripTransitions2UnprocessedTrip); + const tripFillPromises = tripsList.map((t) => + tripTransitions2UnprocessedTrip(t, appConfig), + ); return Promise.all(tripFillPromises).then( (rawTripObjs: (UnprocessedTrip | undefined)[]) => { // Now we need to link up the trips. linking unprocessed trips diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 26dce8056..ec2ee9d97 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -5,17 +5,18 @@ import { TimestampRange } from '../types/diaryTypes'; /** * @param url URL endpoint for the request + * @param fetchOpts (optional) options for the fetch request. If 'cache' is set to 'reload', the cache will be ignored * @returns Promise of the fetched response (as text) or cached text from local storage */ -export async function fetchUrlCached(url) { +export async function fetchUrlCached(url: string, fetchOpts?: RequestInit) { const stored = localStorage.getItem(url); - if (stored) { + if (stored && fetchOpts?.cache != 'reload') { logDebug(`fetchUrlCached: found cached data for url ${url}, returning`); return Promise.resolve(stored); } try { - logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`); - const response = await fetch(url); + logDebug(`fetchUrlCached: cache had ${stored} for url ${url}, not using; fetching`); + const response = await fetch(url, fetchOpts); const text = await response.text(); localStorage.setItem(url, text); logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index dbf85ee15..c4d28eee8 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -18,6 +18,8 @@ import TimelineContext from '../../TimelineContext'; import useAppConfig from '../../useAppConfig'; import { getSurveyForTimelineEntry } from './conditionalSurveys'; import useDerivedProperties from '../../diary/useDerivedProperties'; +import { resolveSurveyButtonConfig } from './enketoHelper'; +import { SurveyButtonConfig } from '../../types/appConfigTypes'; type Props = { timelineEntry: any; @@ -33,27 +35,25 @@ const UserInputButton = ({ timelineEntry }: Props) => { const derivedTripProps = useDerivedProperties(timelineEntry); // which survey will this button launch? - const [surveyName, notFilledInLabel] = useMemo(() => { - const tripLabelConfig = appConfig?.survey_info?.buttons?.['trip-label']; - if (!tripLabelConfig) { - // config doesn't specify; use default - return ['TripConfirmSurvey', t('diary.choose-survey')]; - } - // config lists one or more surveys; find which one to use - const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry, derivedTripProps); - const lang = i18n.resolvedLanguage || 'en'; - return [s?.surveyName, s?.['not-filled-in-label'][lang]]; + const survey = useMemo(() => { + if (!appConfig) return null; // no config loaded yet; show blank for now + const possibleSurveysForButton = resolveSurveyButtonConfig(appConfig, 'trip-label'); + // if there is only one survey, no need to check further + if (possibleSurveysForButton.length == 1) return possibleSurveysForButton[0]; + // config lists one or more surveys; find which one to use for this timeline entry + return getSurveyForTimelineEntry(possibleSurveysForButton, timelineEntry, derivedTripProps); }, [appConfig, timelineEntry, i18n.resolvedLanguage]); - // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo( - () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined, - [userInputFor(timelineEntry)?.['SURVEY']?.data.label], - ); + // the label resolved from the survey response, or undefined if there is no response yet + const responseLabel = useMemo(() => { + if (!survey) return undefined; + return userInputFor(timelineEntry)?.[survey.surveyName]?.data.label || undefined; + }, [survey, userInputFor(timelineEntry)?.[survey?.surveyName || '']?.data.label]); function launchUserInputSurvey() { + if (!survey) return displayErrorMsg('UserInputButton: no survey to launch'); logDebug('UserInputButton: About to launch survey'); - const prevResponse = userInputFor(timelineEntry)?.['SURVEY']; + const prevResponse = userInputFor(timelineEntry)?.[survey.surveyName]; if (prevResponse?.data?.xmlResponse) { setPrevSurveyResponse(prevResponse.data.xmlResponse); } @@ -64,27 +64,27 @@ const UserInputButton = ({ timelineEntry }: Props) => { if (result) { logDebug(`UserInputButton: response was saved, about to addUserInputToEntry; result = ${JSON.stringify(result)}`); - addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label'); + addUserInputToEntry(timelineEntry._id.$oid, { [result.name]: result }, 'label'); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } - if (!surveyName) return <>; // no survey to launch + if (!survey) return <>; // no survey to launch return ( <> launchUserInputSurvey()}> - {responseLabel || notFilledInLabel} + {responseLabel || survey['not-filled-in-label'][i18n.resolvedLanguage || 'en']} setModalVisible(false)} onResponseSaved={onResponseSaved} - surveyName={surveyName} + surveyName={survey.surveyName} opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} /> diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts index 607b49431..a96ee2de8 100644 --- a/www/js/survey/enketo/conditionalSurveys.ts +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -29,26 +29,22 @@ const scopedEval = (script: string, scope: { [k: string]: any }) => // the first survey in the list that passes its condition will be returned export function getSurveyForTimelineEntry( - tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[], + possibleSurveys: SurveyButtonConfig[], tlEntry: TimelineEntry, derivedProperties: DerivedProperties, ) { - // if only one survey is given, just return it - if (!(tripLabelConfig instanceof Array)) return tripLabelConfig; - if (tripLabelConfig.length == 1) return tripLabelConfig[0]; - // else we have an array of possible surveys, we need to find which one to use for this entry - for (let surveyConfig of tripLabelConfig) { - if (!surveyConfig.showsIf) return surveyConfig; // survey shows unconditionally + for (let survey of possibleSurveys) { + if (!survey.showsIf) return survey; // survey shows unconditionally const scope = { ...tlEntry, ...derivedProperties, ...conditionalSurveyFunctions, }; try { - const evalResult = scopedEval(surveyConfig.showsIf, scope); - if (evalResult) return surveyConfig; + const evalResult = scopedEval(survey.showsIf, scope); + if (evalResult) return survey; } catch (e) { - displayError(e, `Error evaluating survey condition "${surveyConfig.showsIf}"`); + displayError(e, `Error evaluating survey condition "${survey.showsIf}"`); } } // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default? diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 2df2d3b2d..e90354856 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -8,7 +8,7 @@ import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; -import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { AppConfig, EnketoSurveyConfig, SurveyButtonConfig } from '../../types/appConfigTypes'; import { CompositeTrip, ConfirmedPlace, @@ -315,6 +315,32 @@ export function loadPreviousResponseForSurvey(dataKey: string) { ); } +/** + * @description Returns an array of surveys that could be prompted for one button in the UI (trip label, trip notes, place label, or place notes) + * (If multiple are returned, they will show conditionally in the UI based on their `showsIf` field) + * Includes backwards compats for app config fields that didn't use to exist + */ +export function resolveSurveyButtonConfig( + config: AppConfig, + button: 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes', +): SurveyButtonConfig[] { + const buttonConfig = config.survey_info.buttons?.[button]; + // backwards compat: default to the trip confirm survey if this button isn't configured + if (!buttonConfig) { + return [ + { + surveyName: 'TripConfirmSurvey', + 'not-filled-in-label': { + en: 'Add Trip Details', + es: 'Agregar detalles del viaje', + lo: 'ເພີ່ມລາຍລະອຽດການເດີນທາງ', + }, + }, + ]; + } + return buttonConfig instanceof Array ? buttonConfig : [buttonConfig]; +} + export async function fetchSurvey(url: string) { const responseText = await fetchUrlCached(url); if (!responseText) return; diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index d4b281713..512b272c4 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -8,7 +8,8 @@ import i18next from 'i18next'; -const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY']; +const unlabeledCheck = (trip, userInputForTrip) => + !userInputForTrip || !Object.values(userInputForTrip).some((input) => input); const TO_LABEL = { key: 'to_label', diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 5bc7c87da..604c533b2 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -18,7 +18,7 @@ import { inputType2retKey, removeManualPrefix, } from './multilabel/confirmHelper'; -import { TimelineLabelMap, TimelineNotesMap } from '../TimelineContext'; +import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../TimelineContext'; import { MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from './enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; @@ -216,9 +216,8 @@ export function getAdditionsForTimelineEntry( return []; } - // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => + // filter out additions that do not start within the bounds of the timeline entry + const matchingAdditions = additionsList.filter((ui) => validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), ); @@ -280,16 +279,16 @@ export function mapInputsToTimelineEntries( allEntries.forEach((tlEntry, i) => { const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { - // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs + // ENKETO configuration: consider reponses from all surveys in unprocessedLabels const userInputForTrip = getUserInputForTimelineEntry( tlEntry, nextEntry, - unprocessedLabels['SURVEY'], + Object.values(unprocessedLabels).flat(1), ) as EnketoUserInputEntry; if (userInputForTrip) { - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + timelineLabelMap[tlEntry._id.$oid] = { [userInputForTrip.data.name]: userInputForTrip }; } else { - let processedSurveyResponse; + let processedSurveyResponse: EnketoUserInputEntry | undefined; for (const dataKey of keysForLabelInputs(appConfig)) { const key = removeManualPrefix(dataKey); if (tlEntry.user_input?.[key]) { @@ -297,12 +296,16 @@ export function mapInputsToTimelineEntries( break; } } - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse }; + if (processedSurveyResponse) { + timelineLabelMap[tlEntry._id.$oid] = { + [processedSurveyResponse.data.name]: processedSurveyResponse, + }; + } } } else { // MULTILABEL configuration: use the label inputs from the labelOptions to determine which // keys to look for in the unprocessedInputs - const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {}; + const labelsForTrip: UserInputMap = {}; Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => { // Check unprocessed labels first since they are more recent const userInputForTrip = getUserInputForTimelineEntry( diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 8ba6acca4..bc3a2b717 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -91,12 +91,12 @@ export function getLabelInputDetails(appConfigParam?) { export function labelInputDetailsForTrip(userInputForTrip, appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (appConfig.intro.mode_studied) { - if (userInputForTrip?.['MODE']?.value == appConfig.intro.mode_studied) { - logDebug(`Found trip labeled with mode of study, ${appConfig.intro.mode_studied}. + if (userInputForTrip?.['MODE']?.data?.label == appConfig.intro.mode_studied) { + logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, mode of study = ${appConfig.intro.mode_studied}. Needs REPLACED_MODE`); return getLabelInputDetails(); } else { - logDebug(`Found trip not labeled with mode of study, ${appConfig.intro.mode_studied}. + logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, not labeled with mode of study = ${appConfig.intro.mode_studied}. Doesn't need REPLACED_MODE`); return baseLabelInputDetails; } diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index d5a15fe4a..e58b679f5 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -54,7 +54,7 @@ export type SurveyButtonConfig = { 'not-filled-in-label': { [lang: string]: string; }; - showsIf: string; // a JS expression that evaluates to a boolean + showsIf?: string; // a JS expression that evaluates to a boolean }; export type SurveyButtonsConfig = { [k in 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes']: diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 9757e95cf..53b618be0 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -4,6 +4,7 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; import useDerivedProperties from '../diary/useDerivedProperties'; +import { VehicleIdentity } from './appConfigTypes'; import { MultilabelKey } from './labelTypes'; import { BEMData, LocalDt } from './serverData'; import { FeatureCollection, Feature, Geometry, Point, Position } from 'geojson'; @@ -58,6 +59,8 @@ export type CompositeTripLocation = { export type UnprocessedTrip = { _id: ObjectId; additions: []; // unprocessed trips won't have any matched processed inputs, so this is always empty + ble_sensed_summary: SectionSummary; + cleaned_section_summary: SectionSummary; confidence_threshold: number; distance: number; duration: number; @@ -67,6 +70,7 @@ export type UnprocessedTrip = { end_ts: number; expectation: { to_label: true }; // unprocessed trips are always expected to be labeled inferred_labels: []; // unprocessed trips won't have inferred labels + inferred_section_summary: SectionSummary; key: 'UNPROCESSED_trip'; locations?: CompositeTripLocation[]; origin_key: 'UNPROCESSED_trip'; @@ -85,6 +89,7 @@ export type UnprocessedTrip = { export type CompositeTrip = { _id: ObjectId; additions: UserInputEntry[]; + ble_sensed_summary: SectionSummary; cleaned_section_summary: SectionSummary; cleaned_trip: ObjectId; confidence_threshold: number; @@ -202,6 +207,7 @@ export type SectionData = { key: string; origin_key: string; trip_id: ObjectId; + ble_sensed_mode: VehicleIdentity; sensed_mode: number; source: string; // e.x., "SmoothedHighConfidenceMotion" start_ts: number; // Unix diff --git a/www/js/usePermissionStatus.ts b/www/js/usePermissionStatus.ts index 8c3128bea..81c4abf54 100644 --- a/www/js/usePermissionStatus.ts +++ b/www/js/usePermissionStatus.ts @@ -291,6 +291,41 @@ const usePermissionStatus = () => { setCheckList(tempChecks); } + function setupAndroidBluetoothChecks() { + if (window['device'].version.split('.')[0] >= 10) { + let fixPerms = () => { + logDebug('fix and refresh bluetooth permissions'); + return checkOrFix( + bluetoothPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixBluetoothPermissions, + true, + ).then((error) => { + if (error) { + bluetoothPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = () => { + logDebug('fix and refresh bluetooth permissions'); + return checkOrFix( + bluetoothPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidBluetoothPermissions, + false, + ); + }; + + let bluetoothPermissionsCheck = { + name: 'Bluetooth scan permission', + desc: 'Scan for BLE beacons to automatically match trips to vehicles', + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(bluetoothPermissionsCheck); + setCheckList(tempChecks); + } + } + function setupAndroidNotificationChecks() { let fixPerms = () => { logDebug('fix and refresh notification permissions'); @@ -372,7 +407,11 @@ const usePermissionStatus = () => { refresh: checkBatteryOpt, }; let tempChecks = checkList; - tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); + if (appConfig.tracking?.bluetooth_only) { + tempChecks.push(ignoreBatteryOptCheck); + } else { + tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); + } setCheckList(tempChecks); } @@ -409,6 +448,9 @@ const usePermissionStatus = () => { if (window['device'].platform.toLowerCase() == 'android') { setupAndroidLocChecks(); setupAndroidFitnessChecks(); + if (appConfig.tracking?.bluetooth_only) { + setupAndroidBluetoothChecks(); + } setupAndroidNotificationChecks(); setupAndroidBackgroundRestrictionChecks(); } else if (window['device'].platform.toLowerCase() == 'ios') {