diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 16edea0..aa8777e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -166,6 +166,7 @@ dependencies { compile 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.2.+' compile project(':react-native-maps') compile project(':react-native-image-picker') + compile project(':react-native-fs') } // Run this once to be able to run the application with BUCK diff --git a/mobile/android/app/src/main/java/com/deightonar/MainApplication.java b/mobile/android/app/src/main/java/com/deightonar/MainApplication.java index caf8f52..c0d3349 100644 --- a/mobile/android/app/src/main/java/com/deightonar/MainApplication.java +++ b/mobile/android/app/src/main/java/com/deightonar/MainApplication.java @@ -17,6 +17,8 @@ import com.airbnb.android.react.maps.MapsPackage; import com.imagepicker.ImagePickerPackage; +import com.rnfs.RNFSPackage; + public class MainApplication extends Application implements ReactApplication { private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { @@ -31,7 +33,8 @@ public class MainApplication extends Application implements ReactApplication { new MainReactPackage(), new ReactViroPackage(ReactViroPackage.ViroPlatform.GVR), new MapsPackage(), - new ImagePickerPackage() + new ImagePickerPackage(), + new RNFSPackage() ); } diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 8278951..2a25eab 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -13,3 +13,6 @@ project(':react-native-maps').projectDir = new File(rootProject.projectDir, '../ include ':react-native-image-picker' project(':react-native-image-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-picker/android') + +include ':react-native-fs' +project(':react-native-fs').projectDir = new File(settingsDir, '../node_modules/react-native-fs/android') \ No newline at end of file diff --git a/mobile/ios/DeightonAR.xcodeproj/project.pbxproj b/mobile/ios/DeightonAR.xcodeproj/project.pbxproj index 60389ed..5f58d5c 100644 --- a/mobile/ios/DeightonAR.xcodeproj/project.pbxproj +++ b/mobile/ios/DeightonAR.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ "${BUILT_PRODUCTS_DIR}/Folly/folly.framework", "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", "${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework", + "${BUILT_PRODUCTS_DIR}/RNFS/RNFS.framework", "${BUILT_PRODUCTS_DIR}/React/React.framework", "${PODS_ROOT}/../../node_modules/react-viro/ios/dist/ViroRenderer/ViroKit.framework", "${BUILT_PRODUCTS_DIR}/glog/glog.framework", @@ -216,6 +217,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/folly.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RNFS.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ViroKit.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/glog.framework", diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index c89c591..03e59a0 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -35,6 +35,8 @@ target 'DeightonAR' do pod 'ViroKit', :path => '../node_modules/react-viro/ios/dist/ViroRenderer/' pod 'react-native-image-picker', :path => '../node_modules/react-native-image-picker' + + pod 'RNFS', :path => '../node_modules/react-native-fs' end # See https://gist.github.com/Jpunt/3fe75effd54a702034b75ff697e47578 diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 1fe9992..408c21c 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -60,6 +60,8 @@ PODS: - React/Core - React/fishhook - React/RCTBlob + - RNFS (2.9.12): + - React - ViroKit (1.0): - AWSDynamoDB (~> 2.6.7) - GVRAudioSDK (= 1.120.0) @@ -82,6 +84,7 @@ DEPENDENCIES: - React/RCTNetwork (from `../node_modules/react-native`) - React/RCTText (from `../node_modules/react-native`) - React/RCTWebSocket (from `../node_modules/react-native`) + - RNFS (from `../node_modules/react-native-fs`) - ViroKit (from `../node_modules/react-viro/ios/dist/ViroRenderer/`) - ViroReact (from `../node_modules/react-viro/ios/`) - yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -109,6 +112,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-maps: :path: "../node_modules/react-native-maps" + RNFS: + :path: "../node_modules/react-native-fs" ViroKit: :path: "../node_modules/react-viro/ios/dist/ViroRenderer/" ViroReact: @@ -130,10 +135,11 @@ SPEC CHECKSUMS: React: aa2040dbb6f317b95314968021bd2888816e03d5 react-native-image-picker: 42cfe2c8435d893414f8714a81e480313cb1412b react-native-maps: 066c2afcc89e18726377bcc685315f989ca22449 + RNFS: bbb1a64eb245763daf34aea86f97c97c4e85f74c ViroKit: 9631f301ef6a3f56116b23d6aac5d5c2307aa368 ViroReact: 5520f26ac4654e361786c82da3b29ce0402c3c00 yoga: a23273df0088bf7f2bb7e5d7b00044ea57a2a54a -PODFILE CHECKSUM: bf7fd2f2a19c210b54a09cfb216d5f930cf6601c +PODFILE CHECKSUM: 5c148f4a189f391c884f82181ca6fc7bf1d45d9c COCOAPODS: 1.5.0 diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 3c618c8..879418c 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -2051,6 +2051,11 @@ } } }, + "base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs=" + }, "base64-arraybuffer": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", @@ -5214,6 +5219,15 @@ "prop-types": "^15.5.10" } }, + "react-native-fs": { + "version": "2.9.12", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.9.12.tgz", + "integrity": "sha512-kppfQwMEmEerP9KImdzRi49ko0pmZtzHzIhpDSkjQVrpTPG7AEzYxdAqapaDveHM8CcN6tzqmiljrueqlBr1VA==", + "requires": { + "base-64": "^0.1.0", + "utf8": "^2.1.1" + } + }, "react-native-image-picker": { "version": "0.26.7", "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-0.26.7.tgz", @@ -6682,6 +6696,11 @@ } } }, + "utf8": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", + "integrity": "sha1-H6DZJw6b6FDZsFAn9jUZv0ZFfZY=" + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/mobile/package.json b/mobile/package.json index 9b693df..68e9680 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -25,6 +25,7 @@ "react": "^16.3.2", "react-form-binder": "^2.0.0", "react-native": "^0.55.4", + "react-native-fs": "^2.9.12", "react-native-image-picker": "^0.26.7", "react-native-iphone-x-helper": "^1.0.3", "react-native-keyboard-spacer": "^0.4.1", diff --git a/mobile/src/API.js b/mobile/src/API.js index 44f0470..a5478a1 100644 --- a/mobile/src/API.js +++ b/mobile/src/API.js @@ -3,6 +3,7 @@ import io from "socket.io-client" import { AsyncStorage } from "react-native" import autobind from "autobind-decorator" import { config } from "./config" +import RNFS from "react-native-fs" const authTokenKeyName = "AuthToken" const backendKeyName = "Backend" @@ -209,17 +210,15 @@ class API extends EventEmitter { headers.set("Authorization", "Bearer " + this.token) } if (method === "POST" || method === "PUT") { - if (requestOptions.raw) { - const isBase64 = requestOptions.raw.base64 + if (requestOptions.binary) { + const { isBase64, offset } = requestOptions.binary + headers.set( "Content-Type", isBase64 ? "application/base64" : "application/octet-stream" ) - headers.set("Content-Length", requestOptions.raw.length) - headers.set( - "Content-Range", - (isBase64 ? "base64" : "byte") + " " + requestOptions.raw.offset - ) + headers.set("Content-Length", requestBody.length) + headers.set("Content-Range", `byte ${offset}`) fetchOptions.body = requestBody } else { headers.set("Content-Type", "application/json") @@ -236,7 +235,7 @@ class API extends EventEmitter { .then((res) => { return Promise.all([ Promise.resolve(res), - requestOptions.raw && method === "GET" ? res.blob() : res.json(), + requestOptions.binary && method === "GET" ? res.blob() : res.json(), ]) }) .then((arr) => { @@ -429,21 +428,27 @@ class API extends EventEmitter { return promise } - upload(data, progressCallback) { + upload(path, progressCallback) { return new Promise((resolve, reject) => { const chunkSize = 32 * 1024 - const uploadSize = data.length - const numberOfChunks = Math.ceil(uploadSize / chunkSize) + let uploadSize = 0 + let numberOfChunks = 0 let chunk = 0 let uploadId = null const uploadNextChunk = () => { - const start = chunk * chunkSize - const end = Math.min(uploadSize, start + chunkSize) + const offset = chunk * chunkSize + const length = Math.min(chunkSize, uploadSize - offset) - this.post("/assets/upload/" + uploadId, data.slice(start, end), { - raw: { base64: true, length: chunkSize, offset: start }, - }) + RNFS.read(path, length, offset, "base64") + .then((data) => { + return this.post("/assets/upload/" + uploadId, data, { + binary: { + isBase64: true, + offset, + }, + }) + }) .then((uploadData) => { chunk++ if (progressCallback && !progressCallback(uploadData)) { @@ -459,12 +464,18 @@ class API extends EventEmitter { }) } - this.post("/assets/upload", { - uploadSize, - contentType: "image/jpeg", - chunkContentType: "application/base64", - numberOfChunks, - }) + RNFS.stat(path) + .then((stat) => { + uploadSize = stat.size + numberOfChunks = Math.ceil(uploadSize / chunkSize) + + return this.post("/assets/upload", { + uploadSize, + contentType: "image/jpeg", + chunkContentType: "application/base64", + numberOfChunks, + }) + }) .then((uploadData) => { uploadId = uploadData.uploadId uploadNextChunk() diff --git a/mobile/src/ARViewer/ARViewer.js b/mobile/src/ARViewer/ARViewer.js index 272d1b8..64c3fff 100644 --- a/mobile/src/ARViewer/ARViewer.js +++ b/mobile/src/ARViewer/ARViewer.js @@ -62,6 +62,10 @@ class WorkItemSceneAR extends React.Component { return this.arScene .performARHitTestWithRay(orientation.forward) .then((results) => { + if (!results) { + return + } + const forward = orientation.forward const position = orientation.position // Default position is just one meter in front of the user. @@ -69,9 +73,6 @@ class WorkItemSceneAR extends React.Component { [forward[0] * 1.0, forward[1] * 1.0, forward[2]] * 1.0 let hitResultPosition = null - console.log(orientation) - console.log(results) - // Filter the hit test results based on the position. for (var i = 0; i < results.length; i++) { let result = results[i] @@ -109,9 +110,7 @@ class WorkItemSceneAR extends React.Component { this.updateInitialRotation() }, 200) }) - .catch((err) => { - console.log(err) - }) + .catch((err) => {}) }) } diff --git a/mobile/src/Activity/Activity.js b/mobile/src/Activity/Activity.js index b772053..fd3e614 100644 --- a/mobile/src/Activity/Activity.js +++ b/mobile/src/Activity/Activity.js @@ -66,6 +66,7 @@ export class Activity extends React.Component { isValid: (r, v) => v !== "", }, photos: { + initValue: [], isValid: (r, v) => v && v.length > 0, }, status: { diff --git a/mobile/src/Home/Home.js b/mobile/src/Home/Home.js index 74e0aaf..ca1834b 100644 --- a/mobile/src/Home/Home.js +++ b/mobile/src/Home/Home.js @@ -47,8 +47,6 @@ export class Home extends React.Component { workItemDistance: -1, } - this.watchId = null - if (Platform.OS !== "ios") { ensurePermissions( [ @@ -130,13 +128,6 @@ export class Home extends React.Component { }) } - componentWillUnmount() { - if (this.watchId) { - navigator.geolocation.clearWatch(this.watchId) - this.watchId = null - } - } - @autobind handleMessageDismiss() { this.setState({ messageModal: null }) @@ -255,6 +246,17 @@ export class Home extends React.Component { this.setState({ showWorkItems: !this.state.showWorkItems }) } + @autobind + handleCalloutPress(workItem) { + if (api.loggedInUser.administrator) { + this.props.history.push( + `/arviewer?workItemId=${workItem._id}&workItemType=${ + workItem.workItemType + }` + ) + } + } + render() { const { sections, @@ -310,7 +312,7 @@ export class Home extends React.Component { : hardhatPinImage } onPress={(e) => this.handleMarkerPress(e, index)}> - + this.handleCalloutPress(workItem)}> {pad(workItem.ticketNumber, 4) + diff --git a/mobile/src/WorkItem/WorkItem.js b/mobile/src/WorkItem/WorkItem.js index 1d3be3a..ffd17df 100644 --- a/mobile/src/WorkItem/WorkItem.js +++ b/mobile/src/WorkItem/WorkItem.js @@ -29,6 +29,7 @@ import { api } from "../API" import "url-search-params-polyfill" import { config } from "../config" import { workItemTypeEnum, formatLatLng, parseLatLng } from "../util" +import PropTypes from "prop-types" const styles = StyleSheet.create({ container: { @@ -50,6 +51,10 @@ const styles = StyleSheet.create({ }) export class WorkItem extends React.Component { + static propTypes = { + history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + } + static bindings = { header: { noValue: true, @@ -65,6 +70,7 @@ export class WorkItem extends React.Component { isReadOnly: true, }, photos: { + initValue: [], isValid: (r, v) => v && v.length > 0, }, details: { @@ -270,6 +276,13 @@ export class WorkItem extends React.Component { this.setState({ progressModal: null }) } + @autobind + handleAddActivity() { + if (this.history) { + this.history.push(`/activity?workItemId=${this.binder._id}`) + } + } + render() { const { binder, @@ -360,6 +373,26 @@ export class WorkItem extends React.Component { onUploadProgress={this.handleUploadProgress} /> + {api.loggedInUser.administrator && + binder._id && ( + + + + Add Activity + + + + )} {isIphoneX ? : null} { @@ -64,7 +65,7 @@ export class BoundPhotoPanel extends Component { onUploadStarted() } api - .upload(response.data, this.props.onUploadProgress) + .upload(response.path, this.props.onUploadProgress) .then((uploadData) => { if (onUploadEnded) { onUploadEnded(true, uploadData) @@ -74,7 +75,7 @@ export class BoundPhotoPanel extends Component { if (binder) { const value = binder.getFieldValue(name) - let newValue = value.slice(0) + let newValue = typeof value === "array" ? value.slice(0) : [] newValue[index] = uploadData.assetId diff --git a/server/package.json b/server/package.json index 972ddbc..a8d67b3 100644 --- a/server/package.json +++ b/server/package.json @@ -48,7 +48,6 @@ "redis": "^2.7.1", "redis-rstream": "^0.1.3", "regexp-pattern": "^1.0.4", - "safe-buffer": "^5.1.1", "socket.io": "^2.0.3", "tmp-promise": "^1.0.4", "urlsafe-base64": "^1.0.0", diff --git a/server/src/api/routes/AssetRoutes.js b/server/src/api/routes/AssetRoutes.js index 5bd271f..c58ef6e 100644 --- a/server/src/api/routes/AssetRoutes.js +++ b/server/src/api/routes/AssetRoutes.js @@ -6,12 +6,10 @@ import path from "path" import util from "util" import config from "config" import autobind from "autobind-decorator" -import Buffer from "safe-buffer" -import B64 from "b64" import { PassThrough } from "stream" import { catchAll } from "." -function pipeToGridFS(readable, writable, decoder) { +function pipeToGridFS(readable, writeable) { const promise = new Promise((resolve, reject) => { readable.on("error", (error) => { reject(error) @@ -23,13 +21,13 @@ function pipeToGridFS(readable, writable, decoder) { resolve(file) }) }) - readable.pipe(decoder).pipe(writeable) + readable.pipe(writeable) return promise } @autobind export class AssetRoutes { - static rangeRegex = /^(byte|base64) (\d+)/ + static rangeRegex = /^byte (\d+)/ constructor(container) { const app = container.app @@ -73,13 +71,13 @@ export class AssetRoutes { assetId = assetId.slice(0, extIndex) } - const cursor = await this.db.gridfs.findOne({ _id: assetId }) + const cursor = await this.db.gridfs.find({ _id: assetId }) + const file = await cursor.next() - if (!cursor) { + if (!file) { throw createError.NotFound(`Asset ${assetId} was not found`) } - const file = cursor.next() const ifNoneMatch = req.get("If-None-Match") if (ifNoneMatch && ifNoneMatch === file.md5) { @@ -138,7 +136,7 @@ export class AssetRoutes { chunkContentType !== "application/base64" ) { throw createError.BadRequest( - "chunkContentType must be application/octet-stream or application/base64" + "chunkContentType must be 'application/octet-stream' or 'application/base64'" ) } } else { @@ -175,58 +173,47 @@ export class AssetRoutes { throw createError.BadRequest(`Bad upload id ${uploadId}`) } - if (!contentType.startsWith(uploadData.chunkContentType)) { - throw createError.BadRequest( - `Content-Type ${contentType} does not match chunk type ${ - uploadData.chunkContentType - }` - ) - } - - if (parseInt(contentLength, 10) !== req.body.length) { - throw createError.BadRequest( - "Must supply Content-Length header matching length of request body" - ) - } - - let match = contentRange.match(AssetRoutes.rangeRegex) - - if (!match || match.length !== 3) { - throw createError.BadRequest( - "Content-Range header must be supplied and of form '[byte|base64] '" - ) - } - - const [, contentOffsetUnit, contentOffset] = match - - if ( - (uploadData.chunkContentType === "application/octet-stream" && - contentOffsetUnit !== "byte") || - (uploadData.chunkContentType === "application/base64" && - contentOffsetUnit !== "base64") - ) { - throw createError.BadRequest( - `Content-Range offset unit must be ${ - uploadData.chunkContentType === "application/base64" - ? "base64" - : "byte" - }` - ) - } - - let offset = Number.parseInt(contentOffset) - - if (offset < 0 || offset + req.body.length > uploadData.uploadSize) { - throw createError.BadRequest( - `Illegal Content-Range ${contentOffsetType} ${contentOffset} and Content-Length ${contentLength} for upload size ${ - uploadData.uploadSize - }` - ) - } - try { + if (!contentType.startsWith(uploadData.chunkContentType)) { + throw createError.BadRequest( + `Content-Type ${contentType} does not match chunk type ${ + uploadData.chunkContentType + }` + ) + } + + if (parseInt(contentLength, 10) !== req.body.length) { + throw createError.BadRequest( + "Must supply Content-Length header matching length of request body" + ) + } + + let match = contentRange.match(AssetRoutes.rangeRegex) + + if (!match || match.length !== 2) { + throw createError.BadRequest( + "Content-Range header must be supplied and of form 'byte '" + ) + } + + const [, contentOffset] = match + let offset = Number.parseInt(contentOffset) + + const data = + uploadData.chunkContentType === "application/base64" + ? Buffer.from(req.body, "base64") + : req.body + + if (offset < 0 || offset + data.length > uploadData.uploadSize) { + throw createError.BadRequest( + `Illegal Content-Range 'byte ${contentOffset}' and Content-Length ${contentLength} for upload size ${ + uploadData.uploadSize + }` + ) + } + const [, uploadedChunks] = await Promise.all([ - this.rs.setrangeAsync(uploadDataId, offset, req.body), + this.rs.setrangeAsync(uploadDataId, offset, data), this.rs.incrAsync(uploadCountId), ]) const chunkInfo = { @@ -242,11 +229,7 @@ export class AssetRoutes { { contentType: uploadData.contentType } ) - const decoder = - uploadData.chunkContentType === "application/base64" - ? new B64.Decoder() - : new PassThrough() - const file = await pipeToGridFS(readable, writeable, decoder) + const file = await pipeToGridFS(readable, writeable) await Promise.all([ this.rs.del(uploadId), @@ -275,7 +258,6 @@ export class AssetRoutes { this.rs.del(uploadId) this.rs.del(uploadCountId) this.rs.del(uploadDataId) - this.log.error(error.message) throw error } }