diff --git a/.eslintrc b/.eslintrc index 26fcf71ba..125871f2b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,9 +7,17 @@ "prettier", "prettier/flowtype", "prettier/react", - "prettier/standard" + "prettier/standard", + "plugin:jest/recommended" + ], + "plugins": [ + "flowtype", + "react", + "prettier", + "standard", + "react-native", + "jest" ], - "plugins": ["flowtype", "react", "prettier", "standard", "react-native"], "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 6, @@ -20,7 +28,8 @@ }, "env": { "es6": true, - "react-native/react-native": true + "react-native/react-native": true, + "jest/globals": false }, "rules": { "react/prop-types": 0, diff --git a/__mocks__/react-native-fs.js b/__mocks__/react-native-fs.js new file mode 100644 index 000000000..402759aa2 --- /dev/null +++ b/__mocks__/react-native-fs.js @@ -0,0 +1,14 @@ +/* eslint-env jest/globals */ +const mockedRNFS = { + unlink: jest.fn() +}; + +Object.defineProperty(mockedRNFS, "ExternalDirectoryPath", { + get: jest.fn(() => "__ExternalDirectoryPath__") +}); + +Object.defineProperty(mockedRNFS, "DocumentDirectoryPath", { + get: jest.fn(() => "__DocumentDirectoryPath__") +}); + +module.exports = mockedRNFS; diff --git a/__tests__/App.js b/__tests__/App.js deleted file mode 100644 index a79ec3d58..000000000 --- a/__tests__/App.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @format - * @lint-ignore-every XPLATJSCOPYRIGHT1 - */ - -import 'react-native'; -import React from 'react'; -import App from '../App'; - -// Note: test renderer must be required after react-native. -import renderer from 'react-test-renderer'; - -it('renders correctly', () => { - renderer.create(); -}); diff --git a/package.json b/package.json index 0d0e5ac16..2ff20f5de 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "core-js": "^3.1.2", "date-fns": "^1.30.1", "debug": "^4.1.1", + "eslint-plugin-jest": "^22.6.4", "expo-camera": "^4.0.0", "expo-location": "^4.0.0", "expo-sensors": "^4.0.0", @@ -83,7 +84,7 @@ "babel-plugin-react-native-web": "^0.11.2", "buble": "^0.19.6", "conventional-changelog-cli": "^2.0.17", - "eslint": "^5.14.1", + "eslint": "^5.16.0", "eslint-config-prettier": "^4.1.0", "eslint-config-standard": "^12.0.0", "eslint-plugin-flowtype": "^3.4.2", @@ -108,7 +109,14 @@ "txtgen": "^2.2.2" }, "jest": { - "preset": "react-native" + "preset": "react-native", + "transformIgnorePatterns": [ + "node_modules/(?!(react-native|@react-native-community|react-native-splash-screen|ky|react-native-fs)/)" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/e2e/" + ] }, "detox": { "test-runner": "jest", diff --git a/src/backend/constants.js b/src/backend/constants.js index ad757d4b5..2b8a53baf 100644 --- a/src/backend/constants.js +++ b/src/backend/constants.js @@ -1,7 +1,23 @@ -module.exports = { +// @flow + +/*:: +type ConstantsType = {| STARTING: "STARTING", LISTENING: "LISTENING", CLOSING: "CLOSING", CLOSED: "CLOSED", - ERROR: "ERROR" + ERROR: "ERROR", + TIMEOUT: "TIMEOUT" +|} +*/ + +const Constants /*: ConstantsType */ = { + STARTING: "STARTING", + LISTENING: "LISTENING", + CLOSING: "CLOSING", + CLOSED: "CLOSED", + ERROR: "ERROR", + TIMEOUT: "TIMEOUT" }; + +module.exports = Constants; diff --git a/src/backend/status.js b/src/backend/status.js index dbaefab6a..0a1cecb43 100644 --- a/src/backend/status.js +++ b/src/backend/status.js @@ -10,7 +10,7 @@ class ServerStatus { if (this.intervalId) return; // Don't have two heartbeats this.intervalId = setInterval(() => { rnBridge.channel.post("status", this.state); - }, 2000); + }, 1000); } pauseHeartbeat() { clearInterval(this.intervalId); diff --git a/src/frontend/App.js b/src/frontend/App.js index 3b5681c0d..6a8a437f0 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -74,9 +74,6 @@ const App = () => ( permissions before showing main app screen */} - {/* AppProvider must be after AppLoading because it makes requests to the - mapeo-core server. AppLoading only renders children once the server - is ready and listening */} screen. - */ -class HideSplashScreen extends React.Component<{ children: React.Node }> { - timeoutId: TimeoutID | null; - componentDidMount() { - // We need to leave a slight delay for the map to do an initial render - this.timeoutId = setTimeout(() => { - SplashScreen.hide(); - this.timeoutId = null; - log("hiding splashscreen"); - }, 500); - } - componentWillUnmount() { - if (!this.timeoutId) return; - clearTimeout(this.timeoutId); - SplashScreen.hide(); - } - render() { - return this.props.children; - } -} - type Props = { - children: React.Node, - /** Time (ms) to wait for heartbeat from server before showing error */ - timeout: number + children: React.Node }; type State = { - serverStatus: string, - didTimeout: boolean + serverStatus: ServerStatus | null }; -type AppStateType = "active" | "background" | "inactive"; - /** * Listens to the nodejs-mobile process and only renders children when the * server is up and running and listening. @@ -64,34 +31,12 @@ type AppStateType = "active" | "background" | "inactive"; * the user so they know something is wrong. */ class AppLoading extends React.Component { - static defaultProps = { - timeout: DEFAULT_TIMEOUT - }; - + timeoutId: TimeoutID | null; state = { - serverStatus: status.STARTING, - didTimeout: false + serverStatus: null }; - timeoutId: TimeoutID; - _splashVisible: boolean = true; - _nodeAlive: boolean; - _hasSentStoragePath: boolean; - _hasStoragePermission: boolean; - - constructor(props: Props) { - super(props); - log("REQUESTING NODE START"); - // Start up the node process - nodejs.start("loader.js"); - this._nodeAlive = false; - this.restartTimeout(); - } - - async componentDidMount() { - log("Didmount"); - nodejs.channel.addListener("status", this.handleStatusChange); - AppState.addEventListener("change", this.handleAppStateChange); + componentDidMount() { // $FlowFixMe - needs HOC type to be fixed this.props.requestPermissions([ PERMISSIONS.CAMERA, @@ -100,15 +45,26 @@ class AppLoading extends React.Component { PERMISSIONS.READ_EXTERNAL_STORAGE, PERMISSIONS.WRITE_EXTERNAL_STORAGE ]); + this.timeoutId = setTimeout(() => { + SplashScreen.hide(); + this.timeoutId = null; + log("hiding splashscreen"); + }, 500); + this._sub = api.addServerStateListener(this.handleStatusChange); } componentDidUpdate() { - this.sendStoragePathToNode(); + if (this.hasStoragePermission() && this.state.serverStatus == null) { + api.startServer(); + this.setState({ serverStatus: Constants.STARTING }); + } } componentWillUnmount() { - nodejs.channel.removeListener("status", this.handleStatusChange); - AppState.removeEventListener("change", this.handleAppStateChange); + this._sub.remove(); + if (!this.timeoutId) return; + clearTimeout(this.timeoutId); + SplashScreen.hide(); } hasStoragePermission() { @@ -120,76 +76,21 @@ class AppLoading extends React.Component { ); } - // Once we have permission to access external storage and the nodejs process - // has started, it needs to know about where to store shared data that the - // user can access (normall /sdcard/) - sendStoragePathToNode() { - if (this._hasSentStoragePath) return; - if (!(this.hasStoragePermission() && this._nodeAlive)) return; - nodejs.channel.post("storagePath", RNFS.ExternalDirectoryPath); - this._hasSentStoragePath = true; - } - - handleStatusChange = (serverStatus: string) => { - // We know the process has started once we get the first message - this._nodeAlive = true; - this.sendStoragePathToNode(); - // If we get a heartbeat, restart the timeout timer - if (serverStatus === status.LISTENING) this.restartTimeout(); - // No unnecessary re-renders + handleStatusChange = (serverStatus: ServerStatus) => { if (serverStatus === this.state.serverStatus) return; log("status change", serverStatus); - // Re-rendering during CLOSING or CLOSED causes a crash if reloading the app - // in development mode. No need to update the state for these statuses - if (serverStatus === status.CLOSING || serverStatus === status.CLOSED) - return; this.setState({ serverStatus }); }; - handleAppStateChange = (nextAppState: AppStateType) => { - log("AppState change", nextAppState); - // Clear timeout while app is in the background - if (nextAppState === "active") { - this.restartTimeout(); - this.setState({ serverStatus: status.STARTING }); - } else { - clearTimeout(this.timeoutId); - // Show splashscreen while app is in background - // BUGFIX: don't show splashscreen if it is already showing, otherwise it - // is impossible to hide. This happened on first load because showing the - // permissions request dialog puts the app into the background before it - // finishes loading. - if (!this._splashVisible) { - this._splashVisible = true; - log("showing splash screen"); - SplashScreen.show(); - } - } - }; - - restartTimeout() { - clearTimeout(this.timeoutId); - if (this.state.didTimeout) this.setState({ didTimeout: false }); - this.timeoutId = setTimeout(() => { - this.setState({ didTimeout: true }); - }, this.props.timeout); - } - render() { - if (this.state.didTimeout) return ; - switch (this.state.serverStatus) { - case status.LISTENING: - this._splashVisible = false; - return {this.props.children}; - case status.ERROR: - return ( - - - - ); - default: - log("render", this.state.serverStatus); - return ; + const { serverStatus } = this.state; + if (serverStatus == null) return null; + else if (serverStatus === Constants.ERROR) { + return ; + } else if (serverStatus === Constants.TIMEOUT) { + return ; + } else { + return this.props.children; } } } diff --git a/src/frontend/api.js b/src/frontend/api.js index 8c334d806..79b602d34 100644 --- a/src/frontend/api.js +++ b/src/frontend/api.js @@ -1,6 +1,6 @@ // @flow import "core-js/es/reflect"; -import { PixelRatio } from "react-native"; +import { PixelRatio, PermissionsAndroid } from "react-native"; import ky from "ky"; import nodejs from "nodejs-mobile-react-native"; import RNFS from "react-native-fs"; @@ -11,136 +11,15 @@ import type { Observation, ObservationValue } from "./context/ObservationsContext"; +import { promiseTimeout } from "./lib/utils"; +import STATUS from "./../backend/constants"; + import type { IconSize, ImageSize } from "./types"; import type { Photo } from "./context/DraftObservationContext"; import type { Observation as ServerObservation } from "mapeo-schema"; -const log = debug("mapeo-mobile:api"); -const BASE_URL = "http://127.0.0.1:9081/"; -export const api = ky.extend({ - prefixUrl: BASE_URL, - // No timeout because indexing after first sync takes a long time, which mean - // requests to the server take a long time - timeout: false, - headers: { - "cache-control": "no-cache", - pragma: "no-cache" - } -}); -const pixelRatio = PixelRatio.get(); - -export function getPresets(): Promise { - return api - .get("presets/default/presets.json") - .json() - .then(data => mapToArray(data.presets)); -} - -export function getFields(): Promise { - return api - .get("presets/default/presets.json") - .json() - .then(data => mapToArray(data.fields)); -} - -export function getObservations(): Promise { - return api - .get("observations") - .json() - .then(data => data.map(convertFromServer)); -} - -export function getIconUrl(iconId: string, size: IconSize = "medium"): string { - // Some devices are @4x or above, but we only generate icons up to @3x - // Also we don't have @1.5x, so we round it up - const roundedRatio = Math.min(Math.ceil(pixelRatio), 3); - return `${BASE_URL}presets/default/icons/${iconId}-medium@${roundedRatio}x.png`; -} - -export function getMediaUrl(attachmentId: string, size: ImageSize): string { - return `${BASE_URL}media/${size}/${attachmentId}`; -} - -export function getMediaFileUri(attachmentId: string, size: ImageSize): string { - const dir = RNFS.DocumentDirectoryPath; - return `file://${dir}/media/${size}/${attachmentId.slice( - 0, - 2 - )}/${attachmentId}`; -} - -export function getMapStyleUrl(id: string): string { - return `${BASE_URL}styles/${id}/style.json`; -} - -export function checkMapStyle(id: string): Promise { - return api.get(`styles/${id}/style.json`); -} - -export function savePhoto({ - originalUri, - previewUri, - thumbnailUri -}: Photo): Promise<{| id: string |}> { - if (!originalUri || !previewUri || !thumbnailUri) - return Promise.reject( - new Error("Missing uri for full image or thumbnail to save to server") - ); - const data = { - original: originalUri.replace(/^file:\/\//, ""), - preview: previewUri.replace(/^file:\/\//, ""), - thumbnail: thumbnailUri.replace(/^file:\/\//, "") - }; - const createPromise = api.post("media", { json: data }).json(); - // After images have saved to the server we can delete the versions in local - // cache to avoid filling up space on the phone - const localFiles = Object.values(data); - createPromise - // $FlowFixMe - .then(_ => Promise.all(localFiles.map(path => RNFS.unlink(path)))) - .then(() => log("Deleted temp photos on save", localFiles)) - .catch(err => log("Error deleting local image file", err)); - return createPromise; -} - -export function updateObservation( - id: string, - value: ObservationValue, - options: {| - links: Array, - userId?: $ElementType - |} -): Promise { - const valueForServer = { - ...value, - // work around for a quirk in the api right now, we should probably change - // this to accept a links array. An array is needed if you want to merge - // existing forks - version: options.links[0], - userId: options.userId, - type: "observation", - schemaVersion: 3, - id - }; - return api - .put(`observations/${id}`, { json: valueForServer }) - .json() - .then(serverObservation => convertFromServer(serverObservation)); -} - -export function createObservation( - value: ObservationValue -): Promise { - const valueForServer = { - ...value, - type: "observation", - schemaVersion: 3 - }; - return api - .post("observations", { json: valueForServer }) - .json() - .then(serverObservation => convertFromServer(serverObservation)); -} +export type ServerStatus = $Keys; +export type Subscription = { remove: () => any }; export type ServerPeer = { id: string, @@ -181,42 +60,317 @@ export type ServerPeer = { }; type PeerHandler = (peerList: Array) => any; -type Subscription = { remove: () => void }; - -/** - * Listens to the server for updates to the list of peers available for sync - * returns a remove() function to unscubribe - */ -export function addPeerListener(handler: PeerHandler): Subscription { - // We sidestep the http API here, and instead of polling the endpoint, we - // listen for an event from mapeo-core whenever the peers change, then - // request an updated peer list. - nodejs.channel.addListener("peer-update", handler); - syncGetPeers().then(handler); - return { - remove: () => nodejs.channel.removeListener("peer-update", handler) - }; -} -export function syncJoin(name: string) { - api.get(`sync/join?name=${name}`); -} +export { STATUS as Constants }; -export function syncLeave() { - api.get("sync/leave"); -} +const log = debug("mapeo-mobile:api"); +const BASE_URL = "http://127.0.0.1:9081/"; +const DEFAULT_TIMEOUT = 10000; // 10 seconds +const SERVER_START_TIMEOUT = 10000; -export function syncGetPeers() { - return api - .get("sync/peers") - .json() - .then(data => data && data.message); -} +const pixelRatio = PixelRatio.get(); + +export function Api({ + baseUrl, + timeout = DEFAULT_TIMEOUT +}: { + baseUrl: string, + timeout?: number +}) { + let status: ServerStatus = STATUS.STARTING; + let timeoutId: TimeoutID; + + const req = ky.extend({ + prefixUrl: baseUrl, + // No timeout because indexing after first sync takes a long time, which mean + // requests to the server take a long time + timeout: false, + headers: { + "cache-control": "no-cache", + pragma: "no-cache" + } + }); + + const pending: Array<{ resolve: () => any, reject: Error => any }> = []; + let listeners: Array<(status: ServerStatus) => any> = []; + + nodejs.channel.addListener("status", onStatusChange); + + function onStatusChange(newStatus: ServerStatus) { + status = newStatus; + if (status === STATUS.LISTENING) { + while (pending.length) pending.shift().resolve(); + } else if (status === STATUS.ERROR) { + while (pending.length) pending.shift().reject(new Error("Server Error")); + } else if (status === STATUS.TIMEOUT) { + while (pending.length) + pending.shift().reject(new Error("Server Timeout")); + } + listeners.forEach(handler => handler(status)); + if (status === STATUS.LISTENING || status === STATUS.STARTING) { + restartTimeout(); + } else { + clearTimeout(timeoutId); + } + } + + function restartTimeout() { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => onStatusChange(STATUS.TIMEOUT), timeout); + } + + // Returns a promise that resolves when the server is ready to accept a + // request and rejects if there is an error with server startup + function onReady() { + return new Promise((resolve, reject) => { + log("onReady called", status); + if (status === STATUS.LISTENING) resolve(); + else if (status === STATUS.ERROR) reject(new Error("Server Error")); + else if (status === STATUS.TIMEOUT) reject(new Error("Server Timeout")); + else pending.push({ resolve, reject }); + }); + } + + // Request convenience methods that wait for the server to be ready + function get(url: string) { + return onReady().then(() => req.get(url).json()); + } + function put(url: string, data: any) { + return onReady().then(() => req.put(url, { json: data }).json()); + } + function post(url: string, data: any) { + return onReady().then(() => req.post(url, { json: data }).json()); + } + + // All public methods + const api = { + // Start server, returns a promise that resolves when the server is ready + // or rejects if there is an error starting the server + startServer: function startServer(): Promise { + // The server requires read & write permissions for external storage + const serverStartPromise = PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE + ]) + .then(results => { + const permissionsGranted = Object.values(results).every( + r => r === PermissionsAndroid.RESULTS.GRANTED + ); + if (!permissionsGranted) + throw new Error("Storage read and write permissions not granted"); + log("REQUESTING NODE START"); + nodejs.start("loader.js"); + // We know the node process has started as soon as we hear a status + return new Promise(resolve => nodejs.channel.once("status", resolve)); + }) + .then(() => { + log("FIRST HEARTBEAT FROM NODE"); + // Start monitoring for timeout + restartTimeout(); + // As soon as we hear from the Node process, send the storagePath so + // that the server can start + nodejs.channel.post("storagePath", RNFS.ExternalDirectoryPath); + // Resolve once the server reports status as "LISTENING" + return onReady(); + }); + return promiseTimeout( + serverStartPromise, + SERVER_START_TIMEOUT, + "Server start timeout" + ); + }, + + addServerStateListener: function addServerStateListener( + handler: (status: ServerStatus) => any + ): Subscription { + listeners.push(handler); + return { + remove: () => (listeners = listeners.filter(h => h !== handler)) + }; + }, + + /** + * GET async methods + */ + + getPresets: function getPresets(): Promise { + return get("presets/default/presets.json").then(data => + mapToArray(data.presets) + ); + }, + + getFields: function getFields(): Promise { + return get("presets/default/presets.json").then(data => + mapToArray(data.fields) + ); + }, + + getObservations: function getObservations(): Promise { + return get("observations").then(data => data.map(convertFromServer)); + }, -export function syncStart(target: { host: string, port: number }) { - nodejs.channel.post("sync-start", target); + getMapStyle: function getMapStyle(id: string): Promise { + return get(`styles/${id}/style.json`); + }, + + /** + * PUT and POST methods + */ + + savePhoto: function savePhoto({ + originalUri, + previewUri, + thumbnailUri + }: Photo): Promise<{| id: string |}> { + if (!originalUri || !previewUri || !thumbnailUri) + return Promise.reject( + new Error("Missing uri for full image or thumbnail to save to server") + ); + const data = { + original: originalUri.replace(/^file:\/\//, ""), + preview: previewUri.replace(/^file:\/\//, ""), + thumbnail: thumbnailUri.replace(/^file:\/\//, "") + }; + const createPromise = post("media", data); + // After images have saved to the server we can delete the versions in + // local cache to avoid filling up space on the phone + const localFiles = Object.values(data); + createPromise + // $FlowFixMe - Flow has issues with Object.values + .then(_ => Promise.all(localFiles.map(path => RNFS.unlink(path)))) + .then(() => log("Deleted temp photos on save", localFiles)) + .catch(err => log("Error deleting local image file", err)); + return createPromise; + }, + + updateObservation: function updateObservation( + id: string, + value: ObservationValue, + options: {| + links: Array, + userId?: $ElementType + |} + ): Promise { + const valueForServer = { + ...value, + // work around for a quirk in the api right now, we should probably change + // this to accept a links array. An array is needed if you want to merge + // existing forks + version: options.links[0], + userId: options.userId, + type: "observation", + schemaVersion: 3, + id + }; + return put(`observations/${id}`, valueForServer).then( + (serverObservation: ServerObservation) => + convertFromServer(serverObservation) + ); + }, + + createObservation: function createObservation( + value: ObservationValue + ): Promise { + const valueForServer = { + ...value, + type: "observation", + schemaVersion: 3 + }; + return post("observations", valueForServer).then( + (serverObservation: ServerObservation) => + convertFromServer(serverObservation) + ); + }, + + /** + * SYNC methods + */ + + // Listens to the server for updates to the list of peers available for sync + // returns a remove() function to unscubribe + addPeerListener: function addPeerListener( + handler: PeerHandler + ): Subscription { + // We sidestep the http API here, and instead of polling the endpoint, we + // listen for an event from mapeo-core whenever the peers change, then + // request an updated peer list. + nodejs.channel.addListener("peer-update", handler); + api.syncGetPeers().then(handler); + return { + remove: () => nodejs.channel.removeListener("peer-update", handler) + }; + }, + + // Start listening for sync peers and advertise with `deviceName` + syncJoin: function syncJoin(deviceName: string) { + req.get(`sync/join?name=${deviceName}`); + }, + + // Stop listening for sync peers and stop advertising + syncLeave: function syncLeave() { + req.get("sync/leave"); + }, + + // Get a list of discovered sync peers + syncGetPeers: function syncGetPeers() { + return get("sync/peers").then(data => data && data.message); + }, + + // Start sync with a peer + syncStart: function syncStart(target: { host: string, port: number }) { + return onReady().then(() => nodejs.channel.post("sync-start", target)); + }, + + /** + * HELPER synchronous methods + */ + + // Return the url for an icon + getIconUrl: function getIconUrl( + iconId: string, + size: IconSize = "medium" + ): string { + // Some devices are @4x or above, but we only generate icons up to @3x + // Also we don't have @1.5x, so we round it up + const roundedRatio = Math.min(Math.ceil(pixelRatio), 3); + return `${BASE_URL}presets/default/icons/${iconId}-medium@${roundedRatio}x.png`; + }, + + // Return the url for a media attachment + getMediaUrl: function getMediaUrl( + attachmentId: string, + size: ImageSize + ): string { + return `${BASE_URL}media/${size}/${attachmentId}`; + }, + + // Return the File Uri for a media attachment in local storage. Necessary + // for sharing media with other apps. + // **WARNING**: This depends on internal implementation of the media blob + // store and will break if that changes. I apologise if you reach here after + // some lengthy debugging. + getMediaFileUri: function getMediaFileUri( + attachmentId: string, + size: ImageSize + ): string { + const dir = RNFS.DocumentDirectoryPath; + return `file://${dir}/media/${size}/${attachmentId.slice( + 0, + 2 + )}/${attachmentId}`; + }, + + // Return the url to a map style + getMapStyleUrl: function getMapStyleUrl(id: string): string { + return `${BASE_URL}styles/${id}/style.json`; + } + }; + + return api; } +export default Api({ baseUrl: BASE_URL }); + function mapToArray(map: { [string]: T }): Array { return Object.keys(map).map(id => ({ ...map[id], @@ -224,14 +378,6 @@ function mapToArray(map: { [string]: T }): Array { })); } -// function convertToServer(obs: Observation): ServerObservation { -// const { value, ...other } = obs; -// return { -// ...other, -// ...value -// }; -// } - function convertFromServer(obs: ServerObservation): Observation { const { id, diff --git a/src/frontend/api.spec.js b/src/frontend/api.spec.js new file mode 100644 index 000000000..a22b852f1 --- /dev/null +++ b/src/frontend/api.spec.js @@ -0,0 +1,237 @@ +/* eslint-env jest/globals */ +import ky from "ky"; +import { PermissionsAndroid } from "react-native"; +import nodejs from "nodejs-mobile-react-native"; +import RNFS from "react-native-fs"; +import { Api, Constants } from "./api"; + +// require("debug").enable("*"); + +jest.mock("ky"); +jest.mock("nodejs-mobile-react-native"); +jest.mock("react-native-fs"); +jest.mock("PermissionsAndroid"); + +function mockStoragePerms(result) { + return () => + Promise.resolve({ + [PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE]: result, + [PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE]: result + }); +} + +describe("Server startup", () => { + test("Initialization sets up nodejs status listener", () => { + const spy = jest.spyOn(nodejs.channel, "addListener"); + new Api({ baseUrl: "__URL__" }); // eslint-disable-line no-new + expect(spy).toHaveBeenCalled(); + }); + + test("Start server with permissions granted", async () => { + expect.assertions(5); + nodejs.start = jest.fn(); + // This mocks the initial heartbeat from the server + nodejs.channel.once = jest.fn((_, handler) => handler()); + // This mocks the server to immediately be in "Listening" state + nodejs.channel.addListener = jest.fn((_, handler) => + handler(Constants.LISTENING) + ); + nodejs.channel.post = jest.fn(); + PermissionsAndroid.requestMultiple = jest.fn( + mockStoragePerms(PermissionsAndroid.RESULTS.GRANTED) + ); + const api = new Api({ baseUrl: "__URL__" }); + await expect(api.startServer()).resolves.toBeUndefined(); + expect(nodejs.start.mock.calls.length).toBe(1); + expect(nodejs.start.mock.calls[0][0]).toBe("loader.js"); + expect(nodejs.channel.post.mock.calls.length).toBe(1); + expect(nodejs.channel.post.mock.calls[0][1]).toBe( + RNFS.ExternalDirectoryPath + ); + }); + + test("Start server permissions not granted rejects without trying to start server", async () => { + expect.assertions(3); + nodejs.start = jest.fn(); + // This mocks the initial heartbeat from the server + nodejs.channel.once = jest.fn((_, handler) => handler()); + // This mocks the server to immediately be in "Listening" state + nodejs.channel.addListener = jest.fn((_, handler) => + handler(Constants.LISTENING) + ); + nodejs.channel.post = jest.fn(); + PermissionsAndroid.requestMultiple = jest.fn( + mockStoragePerms(PermissionsAndroid.RESULTS.DENIED) + ); + const api = new Api({ baseUrl: "__URL__" }); + await expect(api.startServer()).rejects.toThrow( + "Storage read and write permissions not granted" + ); + expect(nodejs.start.mock.calls.length).toBe(0); + expect(nodejs.channel.post.mock.calls.length).toBe(0); + }); + + test("Start server timeout", async () => { + jest.useFakeTimers(); + expect.assertions(4); + nodejs.start = jest.fn(); + nodejs.channel.once = jest.fn(); + nodejs.channel.addListener = jest.fn(); + nodejs.channel.post = jest.fn(); + PermissionsAndroid.requestMultiple = jest.fn( + mockStoragePerms(PermissionsAndroid.RESULTS.GRANTED) + ); + const api = new Api({ baseUrl: "__URL__" }); + process.nextTick(() => jest.runAllTimers()); + await expect(api.startServer()).rejects.toThrow("Server start timeout"); + expect(nodejs.start.mock.calls.length).toBe(1); + expect(nodejs.start.mock.calls[0][0]).toBe("loader.js"); + expect(nodejs.channel.post.mock.calls.length).toBe(0); + }); +}); + +describe("Server status", () => { + let subscription; + let serverStatus; + let stateListener; + + beforeEach(() => { + jest.useFakeTimers(); + return startServer().then(spys => { + subscription = spys.subscription; + serverStatus = spys.serverStatus; + stateListener = spys.stateListener; + }); + }); + + test("Timeout", () => { + jest.runAllTimers(); + expect(stateListener).toHaveBeenCalledWith(Constants.TIMEOUT); + }); + test("Timeout only happens once if no other server status message", () => { + jest.advanceTimersByTime(10001); + jest.advanceTimersByTime(10001); + expect(stateListener).toHaveBeenCalledTimes(1); + expect(stateListener).toHaveBeenNthCalledWith(1, Constants.TIMEOUT); + }); + test("After timeout, if server starts listening, timeout starts again", () => { + jest.advanceTimersByTime(10001); + serverStatus(Constants.LISTENING); + jest.advanceTimersByTime(10001); + expect(stateListener).toHaveBeenCalledTimes(3); + expect(stateListener).toHaveBeenNthCalledWith(1, Constants.TIMEOUT); + expect(stateListener).toHaveBeenNthCalledWith(2, Constants.LISTENING); + expect(stateListener).toHaveBeenNthCalledWith(3, Constants.TIMEOUT); + }); + test("After timeout, if server is starting, timeout starts again", () => { + jest.advanceTimersByTime(10001); + serverStatus(Constants.STARTING); + jest.advanceTimersByTime(10001); + expect(stateListener).toHaveBeenCalledTimes(3); + expect(stateListener).toHaveBeenNthCalledWith(1, Constants.TIMEOUT); + expect(stateListener).toHaveBeenNthCalledWith(2, Constants.STARTING); + expect(stateListener).toHaveBeenNthCalledWith(3, Constants.TIMEOUT); + }); + test("After error, timeout will not happen", () => { + serverStatus(Constants.ERROR); + jest.advanceTimersByTime(10001); + expect(stateListener).toHaveBeenCalledTimes(1); + expect(stateListener).toHaveBeenNthCalledWith(1, Constants.ERROR); + }); + test("After server close, timeout will not happen until it restarts", () => { + serverStatus(Constants.CLOSED); + jest.runAllTimers(); + serverStatus(Constants.LISTENING); + jest.runAllTimers(); + expect(stateListener).toHaveBeenCalledTimes(3); + expect(stateListener).toHaveBeenNthCalledWith(1, Constants.CLOSED); + expect(stateListener).toHaveBeenNthCalledWith(2, Constants.LISTENING); + expect(stateListener).toHaveBeenNthCalledWith(3, Constants.TIMEOUT); + }); + test("Unsubscribe works", () => { + subscription.remove(); + serverStatus(Constants.LISTENING); + expect(stateListener).toHaveBeenCalledTimes(0); + }); +}); + +describe("Server get requests", () => { + ky.extend.mockImplementation(() => ({ + get: jest.fn(url => ({ + json: jest.fn(() => { + if (url.startsWith("styles")) return []; + const data = []; + data.presets = {}; + data.fields = {}; + return data; + }) + })) + })); + + ["getObservations", "getPresets", "getFields", "getMapStyle"].forEach( + method => { + test(method + " with server ready", async () => { + const { api } = await startServer(); + expect.assertions(1); + return expect(api[method]()).resolves.toEqual([]); + }); + test(method + " doesn't resolve until server is ready", async () => { + const { api, serverStatus } = await startServer(); + jest.useRealTimers(); + let pending = true; + serverStatus(Constants.STARTING); + expect.assertions(2); + const getPromise = api[method]().finally(() => { + pending = false; + }); + setTimeout(() => { + expect(pending).toBe(true); + serverStatus(Constants.LISTENING); + }, 200); + return expect(getPromise).resolves.toEqual([]); + }); + test(method + " rejects if server timeout", async () => { + const { api, serverStatus } = await startServer(); + jest.useFakeTimers(); + serverStatus(Constants.STARTING); + const getPromise = api[method](); + jest.runAllTimers(); + expect.assertions(1); + return expect(getPromise).rejects.toThrow("Server Timeout"); + }); + test(method + " rejects if server error", async () => { + const { api, serverStatus } = await startServer(); + serverStatus(Constants.ERROR); + const getPromise = api[method](); + expect.assertions(1); + return expect(getPromise).rejects.toThrow("Server Error"); + }); + } + ); +}); + +function startServer() { + const stateListener = jest.fn(); + let serverStatus; + nodejs.start = jest.fn(); + // This mocks the initial heartbeat from the server + nodejs.channel.once = jest.fn((_, handler) => handler()); + nodejs.channel.post = jest.fn(); + PermissionsAndroid.requestMultiple = jest.fn( + mockStoragePerms(PermissionsAndroid.RESULTS.GRANTED) + ); + nodejs.channel.addListener = jest.fn((_, handler) => { + handler(Constants.LISTENING); + serverStatus = handler; + }); + const api = new Api({ baseUrl: "__URL__" }); + return api.startServer().then(() => { + const subscription = api.addServerStateListener(stateListener); + return { + api, + subscription, + serverStatus, + stateListener + }; + }); +} diff --git a/src/frontend/context/ObservationsContext.js b/src/frontend/context/ObservationsContext.js index 83e6d3b3e..51a98994b 100644 --- a/src/frontend/context/ObservationsContext.js +++ b/src/frontend/context/ObservationsContext.js @@ -5,7 +5,7 @@ import hoistStatics from "hoist-non-react-statics"; import pick from "lodash/pick"; import { getDisplayName } from "../lib/utils"; -import { getObservations, createObservation, updateObservation } from "../api"; +import api from "../api"; import type { LocationContextType } from "./LocationContext"; @@ -95,7 +95,7 @@ class ObservationsProvider extends React.Component { log("Reload observations"); this.setState({ loading: true }); try { - const obsList = await getObservations(); + const obsList = await api.getObservations(); this.setState({ observations: new Map(obsList.map(obs => [obs.id, obs])), loading: false @@ -106,7 +106,7 @@ class ObservationsProvider extends React.Component { } create(value: ObservationValue) { - return createObservation(value).then(newObservation => { + return api.createObservation(value).then(newObservation => { this.setState(state => { const cloned = new Map(this.state.observations); log("Created new observation", newObservation); @@ -122,16 +122,18 @@ class ObservationsProvider extends React.Component { log("tried to update observation but can't find it in state"); return Promise.reject(new Error("Observation not found")); } - return updateObservation(id, value, { - links: [existingObservation.version] - }).then(updatedObservation => { - this.setState(state => { - const cloned = new Map(this.state.observations); - log("Updated observation", updatedObservation); - cloned.set(id, updatedObservation); - return { observations: cloned }; + return api + .updateObservation(id, value, { + links: [existingObservation.version] + }) + .then(updatedObservation => { + this.setState(state => { + const cloned = new Map(this.state.observations); + log("Updated observation", updatedObservation); + cloned.set(id, updatedObservation); + return { observations: cloned }; + }); }); - }); } handleError(error: Error) { diff --git a/src/frontend/context/PresetsContext.js b/src/frontend/context/PresetsContext.js index f9f63b9d2..6c7ef1eaf 100644 --- a/src/frontend/context/PresetsContext.js +++ b/src/frontend/context/PresetsContext.js @@ -3,7 +3,7 @@ import * as React from "react"; import debug from "debug"; import memoize from "memoize-one"; -import { getPresets, getFields } from "../api"; +import api from "../api"; import { matchPreset } from "../lib/utils"; import type { ObservationValue } from "./ObservationsContext"; @@ -106,8 +106,8 @@ class PresetsProvider extends React.Component { async componentDidMount() { try { const [presetsList, fieldsList] = await Promise.all([ - getPresets(), - getFields() + api.getPresets(), + api.getFields() ]); this.setState({ presets: new Map(presetsList.map(p => [p.id, p])), diff --git a/src/frontend/lib/utils.js b/src/frontend/lib/utils.js index 002a1c86d..273d698bc 100644 --- a/src/frontend/lib/utils.js +++ b/src/frontend/lib/utils.js @@ -20,6 +20,22 @@ const GOOD_PRECISION = 10; // 10 meters export type LocationStatus = "searching" | "improving" | "good" | "error"; +// Little helper to timeout a promise +export function promiseTimeout( + promise: Promise, + ms: number, + msg?: string +) { + let timeoutId: TimeoutID; + const timeout = new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(msg || "Timeout after " + ms + "ms")); + }, ms); + }); + promise.finally(() => clearTimeout(timeoutId)); + return Promise.race([promise, timeout]); +} + export function getLocationStatus({ position, provider, diff --git a/src/frontend/screens/MapScreen.js b/src/frontend/screens/MapScreen.js index 153f98fef..180ace617 100644 --- a/src/frontend/screens/MapScreen.js +++ b/src/frontend/screens/MapScreen.js @@ -7,7 +7,7 @@ import type { NavigationScreenConfigProps } from "react-navigation"; import MapView from "../sharedComponents/MapView"; import ObservationsContext from "../context/ObservationsContext"; import LocationContext from "../context/LocationContext"; -import { getMapStyleUrl, checkMapStyle } from "../api"; +import api from "../api"; type Props = { ...$Exact, @@ -25,9 +25,9 @@ class MapStyleProvider extends React.Component< async componentDidMount() { try { - const offlineStyleURL = getMapStyleUrl("default"); + const offlineStyleURL = api.getMapStyleUrl("default"); // Check if the mapStyle exists on the server - await checkMapStyle("default"); + await api.getMapStyle("default"); this.setState({ styleURL: offlineStyleURL }); } catch (e) { // If we don't have a default offline style, don't do anything diff --git a/src/frontend/screens/Observation/ObservationView.js b/src/frontend/screens/Observation/ObservationView.js index 6940e2080..891d9a7f3 100644 --- a/src/frontend/screens/Observation/ObservationView.js +++ b/src/frontend/screens/Observation/ObservationView.js @@ -11,7 +11,7 @@ import { } from "react-native"; import ShareMedia from "react-native-share"; -import { getMediaFileUri } from "../../api"; +import api from "../../api"; import FormattedCoords from "../../sharedComponents/FormattedCoords"; import ThumbnailScrollView from "../../sharedComponents/ThumbnailScrollView"; import { DetailsIcon, CategoryIcon } from "../../sharedComponents/icons"; @@ -47,7 +47,9 @@ class ObservationView extends React.Component { const msg = formatShareMessage({ observation, preset }); if (value.attachments && value.attachments.length) { - const urls = value.attachments.map(a => getMediaFileUri(a.id, "preview")); + const urls = value.attachments.map(a => + api.getMediaFileUri(a.id, "preview") + ); const options = { urls: urls, message: msg, diff --git a/src/frontend/screens/ObservationEdit/SaveButton.js b/src/frontend/screens/ObservationEdit/SaveButton.js index 6124da3a3..fffe23362 100644 --- a/src/frontend/screens/ObservationEdit/SaveButton.js +++ b/src/frontend/screens/ObservationEdit/SaveButton.js @@ -7,7 +7,7 @@ import IconButton from "../../sharedComponents/IconButton"; import { SaveIcon } from "../../sharedComponents/icons"; import { withDraft } from "../../context/DraftObservationContext"; import { withObservations } from "../../context/ObservationsContext"; -import { savePhoto } from "../../api"; +import api from "../../api"; import type { NavigationScreenProp } from "react-navigation"; import type { ObservationAttachment, @@ -103,7 +103,7 @@ class SaveButton extends React.PureComponent { return !(attachmentInDraft && attachmentInDraft.deleted); }); - const savedAttachments = await Promise.all(toCreate.map(savePhoto)); + const savedAttachments = await Promise.all(toCreate.map(api.savePhoto)); const newObservationValue = { ...draft.value, attachments: existingAttachments.concat( diff --git a/src/frontend/screens/SyncModal/index.js b/src/frontend/screens/SyncModal/index.js index c67f914c7..642ff45ad 100644 --- a/src/frontend/screens/SyncModal/index.js +++ b/src/frontend/screens/SyncModal/index.js @@ -13,7 +13,7 @@ import OpenSettings from "react-native-android-open-settings"; import KeepAwake from "react-native-keep-awake"; import SyncView from "./SyncView"; -import { syncJoin, syncLeave, addPeerListener, syncStart } from "../../api"; +import api from "../../api"; import { withObservations } from "../../context/ObservationsContext"; import { peerStatus } from "./PeerList"; import type { ObservationsContext } from "../../context/ObservationsContext"; @@ -51,10 +51,10 @@ class SyncModal extends React.Component { componentDidMount() { // When the modal opens, start announcing this device as available for sync - syncJoin(deviceName); + api.syncJoin(deviceName); this._opened = Date.now(); // Subscribe to peer updates - this._subscriptions.push(addPeerListener(this.updatePeers)); + this._subscriptions.push(api.addPeerListener(this.updatePeers)); // Subscribe to NetInfo to know when the user connects/disconnects to wifi this._subscriptions.push( NetInfo.addEventListener( @@ -69,7 +69,7 @@ class SyncModal extends React.Component { componentWillUnmount() { // When the modal closes, stop announcing for sync - syncLeave(); + api.syncLeave(); // Unsubscribe all listeners this._subscriptions.forEach(s => s.remove()); this.props.reload(); @@ -100,7 +100,7 @@ class SyncModal extends React.Component { handleSyncPress = (peerId: string) => { const peer = this.state.serverPeers.find(peer => peer.id === peerId); // Peer could have vanished in the moment the button was pressed - if (peer) syncStart(peer); + if (peer) api.syncStart(peer); }; handleWifiPress = () => { diff --git a/src/frontend/sharedComponents/CameraView.js b/src/frontend/sharedComponents/CameraView.js index d2ec5cb49..6d87716c0 100644 --- a/src/frontend/sharedComponents/CameraView.js +++ b/src/frontend/sharedComponents/CameraView.js @@ -9,6 +9,7 @@ import RNFS from "react-native-fs"; import AddButton from "./AddButton"; import withNavigationFocus from "../lib/withNavigationFocus"; +import { promiseTimeout } from "../lib/utils"; import PermissionsContext, { PERMISSIONS, RESULTS @@ -24,18 +25,6 @@ const captureOptions = { skipProcessing: true }; -// Little helper to timeout a promise -function promiseTimeout(promise: Promise, ms: number, msg?: string) { - let timeoutId: TimeoutID; - const timeout = new Promise((resolve, reject) => { - timeoutId = setTimeout(() => { - reject(new Error(msg || "Timeout after " + ms + "ms")); - }, ms); - }); - promise.finally(() => clearTimeout(timeoutId)); - return Promise.race([promise, timeout]); -} - type Props = { // Called when the user takes a picture, with a promise that resolves to an // object with the property `uri` for the captured (and rotated) photo. diff --git a/src/frontend/sharedComponents/icons/CategoryIcon.js b/src/frontend/sharedComponents/icons/CategoryIcon.js index 2de76558b..9cb823705 100644 --- a/src/frontend/sharedComponents/icons/CategoryIcon.js +++ b/src/frontend/sharedComponents/icons/CategoryIcon.js @@ -4,7 +4,7 @@ import { Image } from "react-native"; import MaterialIcon from "react-native-vector-icons/MaterialIcons"; import Circle from "./Circle"; -import { getIconUrl } from "../../api"; +import api from "../../api"; import type { IconSize } from "../../types"; type IconProps = { @@ -38,7 +38,7 @@ export class CategoryIcon extends React.PureComponent { return ( );