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 (
);