diff --git a/lib/actions/report-actions.js b/lib/actions/report-actions.js index bb9d726fc..79acc70f5 100644 --- a/lib/actions/report-actions.js +++ b/lib/actions/report-actions.js @@ -1,43 +1,66 @@ // @flow -import type { AuthMetadata } from '../shared/identity-client-context'; +import * as React from 'react'; + +import { + IdentityClientContext, + type AuthMetadata, +} from '../shared/identity-client-context.js'; import type { ClientReportCreationRequest, + ReportsServiceSendReportsAction, ReportCreationResponse, } from '../types/report-types.js'; import { sendReports as callSendReports } from '../utils/reports-service.js'; const sendReportActionTypes = Object.freeze({ started: 'SEND_REPORT_STARTED', success: 'SEND_REPORT_SUCCESS', failed: 'SEND_REPORT_FAILED', }); const sendReport: ( request: ClientReportCreationRequest, authMetadata: ?AuthMetadata, ) => Promise = async (request, authMetadata) => { const { reportIDs: [id], } = await callSendReports([request], authMetadata); if (!id) { throw new Error('Server did not return report ID'); } return { id }; }; +function useSendReports(): ReportsServiceSendReportsAction { + const identityContext = React.useContext(IdentityClientContext); + const getAuthMetadata = identityContext?.getAuthMetadata; + + return React.useCallback( + async request => { + let authMetadata; + if (getAuthMetadata) { + authMetadata = await getAuthMetadata(); + } + return callSendReports(request, authMetadata); + }, + [getAuthMetadata], + ); +} + const sendReportsActionTypes = Object.freeze({ started: 'SEND_REPORTS_STARTED', success: 'SEND_REPORTS_SUCCESS', failed: 'SEND_REPORTS_FAILED', }); const queueReportsActionType = 'QUEUE_REPORTS'; export { sendReportActionTypes, sendReport, sendReportsActionTypes, queueReportsActionType, + useSendReports, }; diff --git a/lib/socket/report-handler.react.js b/lib/socket/report-handler.react.js index 658a87554..8014bb144 100644 --- a/lib/socket/report-handler.react.js +++ b/lib/socket/report-handler.react.js @@ -1,91 +1,97 @@ // @flow import * as React from 'react'; -import { sendReportsActionTypes } from '../actions/report-actions.js'; +import { + sendReportsActionTypes, + useSendReports, +} from '../actions/report-actions.js'; import { queuedReports as queuedReportsSelector } from '../selectors/socket-selectors.js'; import { type ClientReportCreationRequest, type ClearDeliveredReportsPayload, + type ReportsServiceSendReportsAction, } from '../types/report-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; -import { sendReports } from '../utils/reports-service.js'; type BaseProps = { +canSendReports: boolean, }; type Props = { ...BaseProps, +queuedReports: $ReadOnlyArray, +dispatchActionPromise: DispatchActionPromise, + +sendReports: ReportsServiceSendReportsAction, }; class ReportHandler extends React.PureComponent { componentDidMount() { if (this.props.canSendReports) { this.dispatchSendReports(this.props.queuedReports); } } componentDidUpdate(prevProps: Props) { if (!this.props.canSendReports) { return; } const couldSend = prevProps.canSendReports; const curReports = this.props.queuedReports; if (!couldSend) { this.dispatchSendReports(curReports); return; } const prevReports = prevProps.queuedReports; if (curReports !== prevReports) { const prevResponses = new Set(prevReports); const newResponses = curReports.filter( response => !prevResponses.has(response), ); this.dispatchSendReports(newResponses); } } render(): React.Node { return null; } dispatchSendReports(reports: $ReadOnlyArray) { if (reports.length === 0) { return; } void this.props.dispatchActionPromise( sendReportsActionTypes, this.sendReports(reports), ); } async sendReports( reports: $ReadOnlyArray, ): Promise { - await sendReports(reports); + await this.props.sendReports(reports); return { reports }; } } const ConnectedReportHandler: React.ComponentType = React.memo(function ConnectedReportHandler(props) { const queuedReports = useSelector(queuedReportsSelector); const dispatchActionPromise = useDispatchActionPromise(); + const callSendReports = useSendReports(); return ( ); }); export default ConnectedReportHandler; diff --git a/lib/types/report-types.js b/lib/types/report-types.js index 7a11626ca..9b6ac2244 100644 --- a/lib/types/report-types.js +++ b/lib/types/report-types.js @@ -1,235 +1,238 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { type PlatformDetails } from './device-types.js'; import { type RawEntryInfo, type CalendarQuery } from './entry-types.js'; import { type MediaMission } from './media-types.js'; import type { AppState, BaseAction } from './redux-types.js'; import { type MixedRawThreadInfos } from './thread-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import { tPlatformDetails, tShape } from '../utils/validation-utils.js'; export type EnabledReports = { +crashReports: boolean, +inconsistencyReports: boolean, +mediaReports: boolean, }; export type SupportedReports = $Keys; export const defaultEnabledReports: EnabledReports = { crashReports: false, inconsistencyReports: false, mediaReports: false, }; export const defaultDevEnabledReports: EnabledReports = { crashReports: true, inconsistencyReports: true, mediaReports: true, }; export type ReportStore = { +enabledReports: EnabledReports, +queuedReports: $ReadOnlyArray, }; export const reportTypes = Object.freeze({ ERROR: 0, THREAD_INCONSISTENCY: 1, ENTRY_INCONSISTENCY: 2, MEDIA_MISSION: 3, USER_INCONSISTENCY: 4, }); type ReportType = $Values; export function assertReportType(reportType: number): ReportType { invariant( reportType === 0 || reportType === 1 || reportType === 2 || reportType === 3 || reportType === 4, 'number is not ReportType enum', ); return reportType; } export type ErrorInfo = { componentStack: string, ... }; export type ErrorData = { error: Error, info?: ErrorInfo }; export type FlatErrorData = { errorMessage: string, stack?: string, componentStack?: ?string, }; export type ActionSummary = { +type: $PropertyType, +time: number, +summary: string, }; export type ThreadInconsistencyReportShape = { +platformDetails: PlatformDetails, +beforeAction: MixedRawThreadInfos, +action: BaseAction, +pollResult?: ?MixedRawThreadInfos, +pushResult: MixedRawThreadInfos, +lastActionTypes?: ?$ReadOnlyArray<$PropertyType>, +lastActions?: ?$ReadOnlyArray, +time?: ?number, }; export type EntryInconsistencyReportShape = { +platformDetails: PlatformDetails, +beforeAction: { +[id: string]: RawEntryInfo }, +action: BaseAction, +calendarQuery: CalendarQuery, +pollResult?: ?{ +[id: string]: RawEntryInfo }, +pushResult: { +[id: string]: RawEntryInfo }, +lastActionTypes?: ?$ReadOnlyArray<$PropertyType>, +lastActions?: ?$ReadOnlyArray, +time: number, }; export type UserInconsistencyReportShape = { +platformDetails: PlatformDetails, +action: BaseAction, +beforeStateCheck: UserInfos, +afterStateCheck: UserInfos, +lastActions: $ReadOnlyArray, +time: number, }; export type ErrorReportCreationRequest = { +type: 0, +platformDetails: PlatformDetails, +errors: $ReadOnlyArray, +preloadedState: AppState, +currentState: AppState, +actions: $ReadOnlyArray, }; export type ThreadInconsistencyReportCreationRequest = { ...ThreadInconsistencyReportShape, +type: 1, }; export type EntryInconsistencyReportCreationRequest = { ...EntryInconsistencyReportShape, +type: 2, }; export type MediaMissionReportCreationRequest = { +type: 3, +platformDetails: PlatformDetails, +time: number, // ms +mediaMission: MediaMission, +uploadServerID?: ?string, +uploadLocalID?: ?string, +mediaLocalID?: ?string, // deprecated +messageServerID?: ?string, +messageLocalID?: ?string, }; export type UserInconsistencyReportCreationRequest = { ...UserInconsistencyReportShape, +type: 4, }; export type ReportCreationRequest = | ErrorReportCreationRequest | ThreadInconsistencyReportCreationRequest | EntryInconsistencyReportCreationRequest | MediaMissionReportCreationRequest | UserInconsistencyReportCreationRequest; export type ClientThreadInconsistencyReportShape = { +platformDetails: PlatformDetails, +beforeAction: MixedRawThreadInfos, +action: BaseAction, +pushResult: MixedRawThreadInfos, +lastActions: $ReadOnlyArray, +time: number, }; export type ClientEntryInconsistencyReportShape = { +platformDetails: PlatformDetails, +beforeAction: { +[id: string]: RawEntryInfo }, +action: BaseAction, +calendarQuery: CalendarQuery, +pushResult: { +[id: string]: RawEntryInfo }, +lastActions: $ReadOnlyArray, +time: number, }; export type ClientErrorReportCreationRequest = { ...ErrorReportCreationRequest, +id: string, }; export type ClientThreadInconsistencyReportCreationRequest = { ...ClientThreadInconsistencyReportShape, +type: 1, +id: string, }; export type ClientEntryInconsistencyReportCreationRequest = { ...ClientEntryInconsistencyReportShape, +type: 2, +id: string, }; export type ClientMediaMissionReportCreationRequest = { ...MediaMissionReportCreationRequest, +id: string, }; export type ClientUserInconsistencyReportCreationRequest = { ...UserInconsistencyReportCreationRequest, +id: string, }; export type ClientReportCreationRequest = | ClientErrorReportCreationRequest | ClientThreadInconsistencyReportCreationRequest | ClientEntryInconsistencyReportCreationRequest | ClientMediaMissionReportCreationRequest | ClientUserInconsistencyReportCreationRequest; export type QueueReportsPayload = { +reports: $ReadOnlyArray, }; export type ClearDeliveredReportsPayload = { +reports: $ReadOnlyArray, }; export type ReportCreationResponse = { +id: string, }; // Reports Service specific types export type ReportsServiceSendReportsRequest = | ClientReportCreationRequest | $ReadOnlyArray; export type ReportsServiceSendReportsResponse = { +reportIDs: $ReadOnlyArray, }; +export type ReportsServiceSendReportsAction = ( + request: ReportsServiceSendReportsRequest, +) => Promise; // Keyserver specific types type ReportInfo = { +id: string, +viewerID: string, +platformDetails: PlatformDetails, +creationTime: number, }; export const reportInfoValidator: TInterface = tShape({ id: t.String, viewerID: t.String, platformDetails: tPlatformDetails, creationTime: t.Number, }); export type FetchErrorReportInfosRequest = { +cursor: ?string, }; export type FetchErrorReportInfosResponse = { +reports: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export type ReduxToolsImport = { +preloadedState: AppState, +payload: $ReadOnlyArray, }; diff --git a/native/crash.react.js b/native/crash.react.js index bc7a6288e..e90c4cf79 100644 --- a/native/crash.react.js +++ b/native/crash.react.js @@ -1,296 +1,300 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import Clipboard from '@react-native-clipboard/clipboard'; import invariant from 'invariant'; import _shuffle from 'lodash/fp/shuffle.js'; import * as React from 'react'; import { View, Text, Platform, StyleSheet, ScrollView, ActivityIndicator, } from 'react-native'; import { sendReportActionTypes, sendReport, } from 'lib/actions/report-actions.js'; import { logOutActionTypes, useLogOut } from 'lib/actions/user-actions.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import { type ErrorData, reportTypes } from 'lib/types/report-types.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { sanitizeReduxReport, type ReduxCrashReport, } from 'lib/utils/sanitization.js'; import sleep from 'lib/utils/sleep.js'; import Button from './components/button.react.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { commCoreModule } from './native-modules.js'; import { persistConfig, codeVersion } from './redux/persist.js'; import { wipeAndExit } from './utils/crash-utils.js'; const errorTitles = ['Oh no!!', 'Womp womp womp...']; type BaseProps = { +errorData: $ReadOnlyArray, }; type Props = { ...BaseProps, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: () => Promise, +crashReportingEnabled: boolean, }; type State = { +errorReportID: ?string, +doneWaiting: boolean, }; class Crash extends React.PureComponent { errorTitle: string = _shuffle(errorTitles)[0]; constructor(props: Props) { super(props); this.state = { errorReportID: null, doneWaiting: !props.crashReportingEnabled, }; } componentDidMount() { if (this.state.doneWaiting) { return; } void this.props.dispatchActionPromise( sendReportActionTypes, this.sendReport(), ); void this.timeOut(); } async timeOut() { // If it takes more than 10s, give up and let the user exit await sleep(10000); this.setState({ doneWaiting: true }); } render(): React.Node { const errorText = [...this.props.errorData] .reverse() .map(errorData => errorData.error.message) .join('\n'); let crashID; if (!this.state.doneWaiting) { crashID = ; } else if (this.state.doneWaiting && this.state.errorReportID) { crashID = ( Crash report ID: {this.state.errorReportID} ); } else { crashID = ( Crash reporting can be enabled in the Profile tab. ); } const buttonStyle = { opacity: Number(this.state.doneWaiting) }; return ( {this.errorTitle} I’m sorry, but the app crashed. {crashID} Here’s some text that’s probably not helpful: {errorText} ); } async sendReport() { // There's a type error here because ActionLogger doesn't understand the // exact shape of the Redux state / actions it is passed. We could solve it // by adding some type params to ActionLogger const rawReduxReport: ReduxCrashReport = ({ preloadedState: actionLogger.preloadedState, currentState: actionLogger.currentState, actions: actionLogger.actions, }: any); const sanitizedReduxReport = sanitizeReduxReport(rawReduxReport); - const result = await sendReport({ - type: reportTypes.ERROR, - platformDetails: { - platform: Platform.OS, - codeVersion, - stateVersion: persistConfig.version, + const authMetadata = await commCoreModule.getCommServicesAuthMetadata(); + const result = await sendReport( + { + type: reportTypes.ERROR, + platformDetails: { + platform: Platform.OS, + codeVersion, + stateVersion: persistConfig.version, + }, + errors: this.props.errorData.map(data => ({ + errorMessage: data.error.message, + stack: data.error.stack, + componentStack: data.info && data.info.componentStack, + })), + ...sanitizedReduxReport, + id: generateReportID(), }, - errors: this.props.errorData.map(data => ({ - errorMessage: data.error.message, - stack: data.error.stack, - componentStack: data.info && data.info.componentStack, - })), - ...sanitizedReduxReport, - id: generateReportID(), - }); + authMetadata, + ); this.setState({ errorReportID: result.id, doneWaiting: true, }); } onPressKill = () => { if (!this.state.doneWaiting) { return; } commCoreModule.terminate(); }; onPressWipe = () => { if (!this.state.doneWaiting) { return; } void this.props.dispatchActionPromise( logOutActionTypes, this.logOutAndExit(), ); }; async logOutAndExit() { try { await this.props.logOut(); } catch (e) {} await wipeAndExit(); } onCopyCrashReportID = () => { invariant(this.state.errorReportID, 'should be set'); Clipboard.setString(this.state.errorReportID); }; } const styles = StyleSheet.create({ button: { backgroundColor: '#FF0000', borderRadius: 5, marginHorizontal: 10, paddingHorizontal: 10, paddingVertical: 5, }, buttonText: { color: 'white', fontSize: 16, }, buttons: { flexDirection: 'row', }, container: { alignItems: 'center', backgroundColor: 'white', flex: 1, justifyContent: 'center', }, copyCrashReportIDButtonText: { color: '#036AFF', }, crashID: { alignItems: 'center', paddingBottom: 12, paddingTop: 2, }, crashIDText: { color: 'black', }, errorReportID: { flexDirection: 'row', height: 20, }, errorReportIDText: { color: 'black', fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), fontSize: 12, paddingRight: 8, paddingTop: 3, }, errorText: { color: 'black', fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), }, header: { color: 'black', fontSize: 24, paddingBottom: 24, }, scrollView: { flex: 1, marginBottom: 24, marginTop: 12, maxHeight: 200, paddingHorizontal: 50, }, text: { color: 'black', paddingBottom: 12, }, }); const ConnectedCrash: React.ComponentType = React.memo( function ConnectedCrash(props: BaseProps) { const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useLogOut(); const crashReportingEnabled = useIsReportEnabled('crashReports'); return ( ); }, ); export default ConnectedCrash;