Skip to content

Commit

Permalink
feat: Don't reload app when switching away & returning
Browse files Browse the repository at this point in the history
Refactor api so that logic to start server and wait for it to be ready is all in api methods, rather than in AppLoading component
  • Loading branch information
gmaclennan committed Jun 2, 2019
1 parent 8029c38 commit 0974a83
Show file tree
Hide file tree
Showing 19 changed files with 684 additions and 362 deletions.
15 changes: 12 additions & 3 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions __mocks__/react-native-fs.js
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 0 additions & 15 deletions __tests__/App.js

This file was deleted.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
20 changes: 18 additions & 2 deletions src/backend/constants.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/backend/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 0 additions & 3 deletions src/frontend/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ const App = () => (
permissions before showing main app screen */}
<PermissionsContext.Provider>
<AppLoading>
{/* 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 */}
<AppProvider>
<AppContainer
persistNavigationState={persistNavigationState}
Expand Down
161 changes: 31 additions & 130 deletions src/frontend/AppLoading.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,27 @@
// @flow
import * as React from "react";
import nodejs from "nodejs-mobile-react-native";
import SplashScreen from "react-native-splash-screen";
import debug from "debug";
import { AppState } from "react-native";
import RNFS from "react-native-fs";

import * as status from "./../backend/constants";
import ServerStatus from "./screens/ServerStatus";
import api, { Constants } from "./api";
import ServerStatusScreen from "./screens/ServerStatus";
import {
withPermissions,
PERMISSIONS,
RESULTS
} from "./context/PermissionsContext";
import type { ServerStatus } from "./api";

const log = debug("mapeo:AppLoading");

const DEFAULT_TIMEOUT = 10000; // 10 seconds

/**
* Hide the spashscreen once children have mounted. If we hide before mounting
* then the user sees the <ServerStatus variant="waiting" /> 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.
Expand All @@ -64,34 +31,12 @@ type AppStateType = "active" | "background" | "inactive";
* the user so they know something is wrong.
*/
class AppLoading extends React.Component<Props, State> {
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,
Expand All @@ -100,15 +45,26 @@ class AppLoading extends React.Component<Props, State> {
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() {
Expand All @@ -120,76 +76,21 @@ class AppLoading extends React.Component<Props, State> {
);
}

// 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 <ServerStatus variant="timeout" />;
switch (this.state.serverStatus) {
case status.LISTENING:
this._splashVisible = false;
return <HideSplashScreen>{this.props.children}</HideSplashScreen>;
case status.ERROR:
return (
<HideSplashScreen>
<ServerStatus variant="error" />
</HideSplashScreen>
);
default:
log("render", this.state.serverStatus);
return <ServerStatus variant="waiting" />;
const { serverStatus } = this.state;
if (serverStatus == null) return null;
else if (serverStatus === Constants.ERROR) {
return <ServerStatusScreen variant="error" />;
} else if (serverStatus === Constants.TIMEOUT) {
return <ServerStatusScreen variant="timeout" />;
} else {
return this.props.children;
}
}
}
Expand Down
Loading

0 comments on commit 0974a83

Please sign in to comment.