diff --git a/keyserver/src/shared/state-sync/users-state-sync-spec.js b/keyserver/src/shared/state-sync/users-state-sync-spec.js index 54c4465fa..10db1efb3 100644 --- a/keyserver/src/shared/state-sync/users-state-sync-spec.js +++ b/keyserver/src/shared/state-sync/users-state-sync-spec.js @@ -1,48 +1,48 @@ // @flow import { usersStateSyncSpec as libSpec } from 'lib/shared/state-sync/users-state-sync-spec.js'; -import type { UserInconsistencyReportCreationRequest } from 'lib/types/report-types.js'; +import type { ClientUserInconsistencyReportCreationRequest } from 'lib/types/report-types.js'; import type { UserInfos, UserInfo } from 'lib/types/user-types.js'; import { userInfoValidator } from 'lib/types/user-types.js'; import { values, hash, combineUnorderedHashes } from 'lib/utils/objects.js'; import type { ServerStateSyncSpec } from './state-sync-spec.js'; import { fetchKnownUserInfos } from '../../fetchers/user-fetchers.js'; import type { Viewer } from '../../session/viewer.js'; import { validateOutput } from '../../utils/validation-utils.js'; export const usersStateSyncSpec: ServerStateSyncSpec< UserInfos, $ReadOnlyArray, UserInfo, - $ReadOnlyArray, + $ReadOnlyArray, > = Object.freeze({ fetch, async fetchFullSocketSyncPayload(viewer: Viewer) { const result = await fetchKnownUserInfos(viewer); return values(result); }, async fetchServerInfosHash(viewer: Viewer, ids?: $ReadOnlySet) { const infos = await fetch(viewer, ids); return getServerInfosHash(infos); }, getServerInfosHash, getServerInfoHash, ...libSpec, }); function fetch(viewer: Viewer, ids?: $ReadOnlySet) { if (ids) { return fetchKnownUserInfos(viewer, [...ids]); } return fetchKnownUserInfos(viewer); } function getServerInfosHash(infos: UserInfos) { return combineUnorderedHashes(values(infos).map(getServerInfoHash)); } function getServerInfoHash(info: UserInfo) { return hash(validateOutput(null, userInfoValidator, info)); } diff --git a/lib/reducers/report-store-reducer.test.js b/lib/reducers/report-store-reducer.test.js index 5bddd5162..8779671c4 100644 --- a/lib/reducers/report-store-reducer.test.js +++ b/lib/reducers/report-store-reducer.test.js @@ -1,311 +1,311 @@ // @flow import reduceReportStore from './report-store-reducer.js'; import type { LogInResult } from '../types/account-types.js'; import type { LoadingInfo } from '../types/loading-types.js'; import type { AppState, BaseAction } from '../types/redux-types.js'; import { type ReportStore, reportTypes, - type MediaMissionReportCreationRequest, - type ErrorReportCreationRequest, + type ClientMediaMissionReportCreationRequest, + type ClientErrorReportCreationRequest, type EnabledReports, type ClientThreadInconsistencyReportCreationRequest, } from '../types/report-types.js'; const loadingInfo: LoadingInfo = { fetchIndex: 0, trackMultipleRequests: false, customKeyName: undefined, }; // this is only for types compatibility and `any` will not have any influence // on tests correctness const defaultState: AppState = ({}: any); const defaultBaseAction: BaseAction = ({ payload: ({}: any), loadingInfo, }: any); const defaultAction = { payload: ({}: any), loadingInfo, }; -const mockErrorReport: ErrorReportCreationRequest = { +const mockErrorReport: ClientErrorReportCreationRequest = { type: reportTypes.ERROR, platformDetails: { platform: 'web' }, errors: [], preloadedState: defaultState, currentState: defaultState, actions: [], id: '1-1', }; const mockInconsistencyReport: ClientThreadInconsistencyReportCreationRequest = { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: { platform: 'web' }, beforeAction: {}, action: defaultBaseAction, pushResult: {}, lastActions: [], time: 0, id: '1-2', }; -const mockMediaReport: MediaMissionReportCreationRequest = { +const mockMediaReport: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, platformDetails: { platform: 'web' }, time: Date.now(), mediaMission: { steps: [], result: { success: true }, totalTime: 0, userTime: 0, }, id: '1-3', }; const defaultEnabledReports: EnabledReports = { crashReports: true, inconsistencyReports: true, mediaReports: true, }; const defaultEmptyReportStore: ReportStore = { queuedReports: [], enabledReports: defaultEnabledReports, }; const defaultReportStore: ReportStore = { queuedReports: [mockErrorReport, mockInconsistencyReport, mockMediaReport], enabledReports: defaultEnabledReports, }; describe('session change test', () => { const mockLogInResult: LogInResult = ({ currentUserInfo: { id: '-1', username: 'test' }, }: any); test('should handle log out', () => { const action = { ...defaultAction, type: 'LOG_OUT_SUCCESS' }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [], ); expect(result.queuedReports).toHaveLength(0); }); test('should handle log out with new inconsistencies', () => { const action = { ...defaultAction, type: 'LOG_OUT_SUCCESS' }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [mockErrorReport], ); expect(result.queuedReports).toHaveLength(0); }); test('should handle log in', () => { const action = { type: 'LOG_IN_SUCCESS', payload: mockLogInResult, loadingInfo, }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [], ); expect(result.queuedReports).toHaveLength(0); }); test('should handle log in with new inconsistencies', () => { const action = { type: 'LOG_IN_SUCCESS', payload: mockLogInResult, loadingInfo, }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [mockErrorReport], ); expect(result.queuedReports).toHaveLength(0); }); }); describe('updateReportsEnabledActionType test', () => { test('should handle the same enabled reports', () => { const action = { type: 'UPDATE_REPORTS_ENABLED', payload: defaultEnabledReports, }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [], ); expect(result.queuedReports).toStrictEqual( defaultReportStore.queuedReports, ); }); test('should handle changing enabled reports', () => { const action = { type: 'UPDATE_REPORTS_ENABLED', payload: { crashReports: true, inconsistencyReports: false, mediaReports: false, }, }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [], ); expect(result.queuedReports).toHaveLength(1); const enabledReportsExist = result.queuedReports.some( report => report.type === reportTypes.ERROR, ); const notEnabledReportsExist = result.queuedReports.some( report => report.type !== reportTypes.ERROR, ); expect(enabledReportsExist).toBeTruthy(); expect(notEnabledReportsExist).toBeFalsy(); }); test('should handle changing enabled reports with new inconsistencies', () => { const action = { type: 'UPDATE_REPORTS_ENABLED', payload: { crashReports: true, inconsistencyReports: false, mediaReports: false, }, }; const { reportStore: result } = reduceReportStore( defaultReportStore, action, [ { ...mockErrorReport, id: 'new-id-error' }, { ...mockMediaReport, id: 'new-id-media' }, ], ); expect(result.queuedReports).toHaveLength(2); const enabledReports = result.queuedReports.filter( report => report.type === reportTypes.ERROR, ); const notEnabledReportsExist = result.queuedReports.some( report => report.type !== reportTypes.ERROR, ); expect(enabledReports).toHaveLength(2); expect(notEnabledReportsExist).toBeFalsy(); }); }); describe('queueReportsActionType test', () => { test('should handle adding enabled report', () => { const action = { type: 'QUEUE_REPORTS', payload: { reports: [ { ...mockErrorReport, id: 'new-id-error' }, { ...mockMediaReport, id: 'new-id-media' }, ], }, }; const reportStore = { queuedReports: [mockErrorReport], enabledReports: { crashReports: true, inconsistencyReports: false, mediaReports: false, }, }; const { reportStore: result } = reduceReportStore(reportStore, action, [ { ...mockErrorReport, id: 'new-id-error-inc' }, { ...mockMediaReport, id: 'new-id-media-inc' }, ]); expect(result.queuedReports).toHaveLength(3); const enabledReports = result.queuedReports.filter( report => report.type === reportTypes.ERROR, ); const notEnabledReportsExist = result.queuedReports.some( report => report.type !== reportTypes.ERROR, ); expect(enabledReports).toHaveLength(3); expect(notEnabledReportsExist).toBeFalsy(); }); }); describe('sending report test', () => { test('should remove sent report', () => { const { reportStore } = reduceReportStore( defaultEmptyReportStore, defaultBaseAction, [mockErrorReport, mockMediaReport], ); expect(reportStore.queuedReports).toHaveLength(2); const [sentReport, notSentReport] = reportStore.queuedReports; const action = { type: 'SEND_REPORT_SUCCESS', payload: { reports: [sentReport], }, loadingInfo, }; const { reportStore: result } = reduceReportStore(reportStore, action, []); expect(result.queuedReports).toHaveLength(1); expect(result.queuedReports).toContain(notSentReport); expect(result.queuedReports).not.toContain(sentReport); }); test('should remove sent report and handle new inconsistencies', () => { const { reportStore } = reduceReportStore( defaultEmptyReportStore, defaultBaseAction, [mockErrorReport, mockMediaReport], ); expect(reportStore.queuedReports).toHaveLength(2); const [sentReport, notSentReport] = reportStore.queuedReports; const action = { type: 'SEND_REPORT_SUCCESS', payload: { reports: [sentReport], }, loadingInfo, }; const { reportStore: result } = reduceReportStore(reportStore, action, [ mockInconsistencyReport, ]); expect(result.queuedReports).toHaveLength(2); expect(result.queuedReports).toContain(notSentReport); expect(result.queuedReports).not.toContain(sentReport); }); }); describe('new inconsistencies test', () => { test('should handle new inconsistencies without any action', () => { const { reportStore } = reduceReportStore( { queuedReports: [mockErrorReport], enabledReports: { crashReports: true, inconsistencyReports: false, mediaReports: false, }, }, defaultBaseAction, [ { ...mockErrorReport, id: 'new-id-error' }, { ...mockMediaReport, id: 'new-id-media' }, ], ); expect(reportStore.queuedReports).toHaveLength(2); }); }); diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 18f0cc833..23e76e4f6 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,254 +1,254 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, setUserSettingsActionTypes, updateUserAvatarActionTypes, resetUserStateActionType, } from '../actions/user-actions.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import type { BaseAction } from '../types/redux-types.js'; -import type { UserInconsistencyReportCreationRequest } from '../types/report-types.js'; +import type { ClientUserInconsistencyReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { processUpdatesActionType } from '../types/update-types.js'; import type { CurrentUserInfo, UserStore } from '../types/user-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { return action.payload.updatesResult.newUpdates.reduce( (reducedState, update) => { const { reduceCurrentUser } = updateSpecs[update.type]; return reduceCurrentUser ? reduceCurrentUser(reducedState, update) : reducedState; }, state, ); } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } else if ( action.type === updateUserAvatarActionTypes.success && state && !state.anonymous ) { const { viewerUpdates } = action.payload.updates; for (const update of viewerUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo.avatar)(state.avatar) ) { return { ...state, avatar: update.currentUserInfo.avatar, }; } } return state; } else if (action.type === setUserSettingsActionTypes.success) { if (state?.settings) { return { ...state, settings: { ...state.settings, ...action.payload, }, }; } } else if (action.type === resetUserStateActionType) { state = state && state.anonymous ? state : null; } return state; } function reduceUserInfos( state: UserStore, action: BaseAction, -): [UserStore, $ReadOnlyArray] { +): [UserStore, $ReadOnlyArray] { if ( action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; if (!_isEqual(state.userInfos)(updated)) { return [ { ...state, userInfos: updated, }, [], ]; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.userInfos).length === 0) { return [state, []]; } return [ { userInfos: {}, }, [], ]; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); if (!_isEqual(state.userInfos)(newUserInfos)) { return [ { userInfos: newUserInfos, }, [], ]; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const updated = action.payload.updatesResult.newUpdates.reduce( (reducedState, update) => { const { reduceUserInfos: reduceUserInfosUpdate } = updateSpecs[update.type]; return reduceUserInfosUpdate ? reduceUserInfosUpdate(reducedState, update) : reducedState; }, { ...state.userInfos, ...newUserInfos }, ); if (!_isEqual(state.userInfos)(updated)) { return [ { ...state, userInfos: updated, }, [], ]; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return [state, []]; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return [state, []]; } const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } const newInconsistencies = stateSyncSpecs.users.findStoreInconsistencies( action, state.userInfos, newUserInfos, ); return [ { userInfos: newUserInfos, }, newInconsistencies, ]; } else if (action.type === updateUserAvatarActionTypes.success) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updates.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; const newState = !_isEqual(state.userInfos)(updated) ? { ...state, userInfos: updated, } : state; return [newState, []]; } return [state, []]; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/shared/state-sync/users-state-sync-spec.js b/lib/shared/state-sync/users-state-sync-spec.js index 8614c67c4..084998c98 100644 --- a/lib/shared/state-sync/users-state-sync-spec.js +++ b/lib/shared/state-sync/users-state-sync-spec.js @@ -1,67 +1,67 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec } from './state-sync-spec.js'; import type { AppState } from '../../types/redux-types'; import { + type ClientUserInconsistencyReportCreationRequest, reportTypes, - type UserInconsistencyReportCreationRequest, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { type UserInfo, type UserInfos } from '../../types/user-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { getConfig } from '../../utils/config.js'; import { combineUnorderedHashes, hash } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; export const usersStateSyncSpec: StateSyncSpec< UserInfos, UserInfo, - $ReadOnlyArray, + $ReadOnlyArray, > = Object.freeze({ hashKey: 'userInfos', innerHashSpec: { hashKey: 'userInfo', deleteKey: 'deleteUserInfoIDs', rawInfosKey: 'userInfos', additionalDeleteCondition(user: UserInfo) { return !user.username; }, }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, ) { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return emptyArray; } return [ { type: reportTypes.USER_INCONSISTENCY, platformDetails: getConfig().platformDetails, action: sanitizeActionSecrets(action), beforeStateCheck, afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector: createSelector( (state: AppState) => state.userStore.userInfos, userInfos => ({ ...usersStateSyncSpec, getInfoHash: id => hash(userInfos[id]), getAllInfosHash: () => combineUnorderedHashes(Object.values(userInfos).map(hash)), getIDs: () => Object.keys(userInfos), }), ), }); const emptyArray = []; diff --git a/lib/types/report-types.js b/lib/types/report-types.js index 3bce5ee90..9f81536ec 100644 --- a/lib/types/report-types.js +++ b/lib/types/report-types.js @@ -1,226 +1,235 @@ // @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 RawThreadInfos } 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: RawThreadInfos, +action: BaseAction, +pollResult?: RawThreadInfos, +pushResult: RawThreadInfos, +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, - +id: string, }; 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, - +id: string, }; export type UserInconsistencyReportCreationRequest = { ...UserInconsistencyReportShape, +type: 4, - +id: string, }; export type ReportCreationRequest = | ErrorReportCreationRequest | ThreadInconsistencyReportCreationRequest | EntryInconsistencyReportCreationRequest | MediaMissionReportCreationRequest | UserInconsistencyReportCreationRequest; export type ClientThreadInconsistencyReportShape = { +platformDetails: PlatformDetails, +beforeAction: RawThreadInfos, +action: BaseAction, +pushResult: RawThreadInfos, +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 = - | ErrorReportCreationRequest + | ClientErrorReportCreationRequest | ClientThreadInconsistencyReportCreationRequest | ClientEntryInconsistencyReportCreationRequest - | MediaMissionReportCreationRequest - | UserInconsistencyReportCreationRequest; + | 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, }; // 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/lib/utils/reports-service.js b/lib/utils/reports-service.js index f8097aca6..7b6fd440b 100644 --- a/lib/utils/reports-service.js +++ b/lib/utils/reports-service.js @@ -1,54 +1,59 @@ // @flow import { fetchWithTimeout } from './fetch-utils.js'; import { reportsServiceURL, sendReportEndpoint, } from '../facts/reports-service.js'; import type { ReportsServiceSendReportsRequest, ReportsServiceSendReportsResponse, + ReportCreationRequest, + ClientReportCreationRequest, } from '../types/report-types.js'; const REQUEST_TIMEOUT = 60 * 1000; // 60s async function sendReports( reports: ReportsServiceSendReportsRequest, ): Promise { - const reportsArray = Array.isArray(reports) ? reports : [reports]; + const reportsArray: $ReadOnlyArray = + Array.isArray(reports) ? reports : [reports]; if (reportsArray.length === 0) { return { reportIDs: [] }; } - const requestBody = reportsArray.map(clientReport => { - const { id, ...report } = clientReport; - return report; - }); + const requestBody: $ReadOnlyArray = reportsArray.map( + clientReport => { + const { id, ...report } = clientReport; + return report; + }, + ); const url = `${reportsServiceURL}${sendReportEndpoint.path}`; const response = await fetchWithTimeout(url, { method: sendReportEndpoint.method, body: JSON.stringify(requestBody), headers: { 'Content-Type': 'application/json', }, timeout: REQUEST_TIMEOUT, }); if (!response.ok) { const { status, statusText } = response; let responseText, errorMessage; try { responseText = await response.text(); } finally { errorMessage = responseText || statusText || '-'; } // we cannot throw error inside `finally` block because eslint complains throw new Error(`Server responded with HTTP ${status}: ${errorMessage}`); } const { reportIDs } = await response.json(); return { reportIDs }; } export { sendReports }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index b928f91cc..98e94e461 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1727 +1,1727 @@ // @flow import * as FileSystem from 'expo-file-system'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, sendTextMessageActionTypes, useSendTextMessage, } from 'lib/actions/message-actions.js'; import type { SendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, useBlobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { isLocalUploadID, getNextLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, useNextLocalID, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { - type MediaMissionReportCreationRequest, + type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, type ThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { CallServerEndpointOptions, CallServerEndpointResponse, } from 'lib/utils/call-server-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { disposeTempFile } from '../media/file-utils.js'; import { processMedia } from '../media/media-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( input: SendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); editInputBarCallbacks: Array< (params: EditInputBarMessageParameters) => void, > = []; scrollToMessageCallbacks: Array<(messageID: string) => void> = []; pendingThreadCreations = new Map>(); pendingThreadUpdateHandlers = new Map mixed>(); // TODO: flip the switch // Note that this enables Blob service for encrypted media only useBlobServiceUploads = false; // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( messageInfo.media, ); try { const result = await this.props.sendMultimediaMessage({ threadID, localID, mediaMessageContents, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, editInputMessage: this.editInputMessage, addEditInputMessageListener: this.addEditInputMessageListener, removeEditInputMessageListener: this.removeEditInputMessageListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, setPendingThreadUpdateHandler: this.setPendingThreadUpdateHandler, scrollToMessage: this.scrollToMessage, addScrollToMessageListener: this.addScrollToMessageListener, removeScrollToMessageListener: this.removeScrollToMessageListener, }), ); scrollToMessage = (messageID: string) => { this.scrollToMessageCallbacks.forEach(callback => callback(messageID)); }; addScrollToMessageListener = (callback: (messageID: string) => void) => { this.scrollToMessageCallbacks.push(callback); }; removeScrollToMessageListener = ( callbackScrollToMessage: (messageID: string) => void, ) => { this.scrollToMessageCallbacks = this.scrollToMessageCallbacks.filter( candidate => candidate !== callbackScrollToMessage, ); }; uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; sendTextMessage = async ( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; const newThreadInfo = { ...threadInfo, id: newThreadID, }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(threadInfo.id); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage({ threadID: messageInfo.threadID, localID, text: messageInfo.text, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } shouldEncryptMedia(threadInfo: ThreadInfo): boolean { return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = this.props.nextLocalID; this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, thumbHash: null, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, thumbnailThumbHash: null, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const messageInfo = createMediaMessageInfo( { localID: localMessageID, threadID: threadInfo.id, creatorID, media, }, { forceMultimediaMessageType: this.shouldEncryptMedia(threadInfo) }, ); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, threadInfo: ThreadInfo, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput, threadInfo), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, threadInfo: ThreadInfo, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps = [selection]; let encryptionSteps = []; let serverID; let userTime; let errorMessage; let reportPromise; const filesToDispose = []; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); steps.push(...encryptionSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } if (this.shouldEncryptMedia(threadInfo)) { const encryptionStart = Date.now(); try { const { result: encryptionResult, ...encryptionReturn } = await encryptMedia(processedMedia); encryptionSteps = encryptionReturn.steps; if (!encryptionResult.success) { onUploadFailed(localMediaID, encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed(localMediaID, 'encryption failed'); return await onUploadFinished({ success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }); } } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { const uploadPromises = []; if ( this.useBlobServiceUploads && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { uploadPromises.push( this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename: filename, mimeType: mime, }, blobHash: processedMedia.blobHash, encryptionKey: processedMedia.encryptionKey, dimensions: processedMedia.dimensions, thumbHash: processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, onProgress: (percent: number) => { this.setProgress( localMessageID, localMediaID, 'uploading', percent, ); }, }, }), ); if (processedMedia.mediaType === 'encrypted_video') { uploadPromises.push( this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: processedMedia.uploadThumbnailURI, filename: replaceExtension(`thumb${filename}`, 'jpg'), mimeType: 'image/jpeg', }, blobHash: processedMedia.thumbnailBlobHash, encryptionKey: processedMedia.thumbnailEncryptionKey, loop: false, dimensions: processedMedia.dimensions, thumbHash: processedMedia.thumbHash, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, }, }), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } else { uploadPromises.push( this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ? processedMedia.loop : undefined, encryptionKey: processedMedia.encryptionKey, thumbHash: processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ), ); if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { uploadPromises.push( this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, encryptionKey: processedMedia.thumbnailEncryptionKey, thumbHash: processedMedia.thumbHash, }, { uploadBlob: this.uploadBlob, }, ), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( ((processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push(...encryptionSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (filesToDispose.length > 0) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete filesToDispose.forEach(shouldDisposePath => { cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); }); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const { steps: clearSteps, result: thumbnailPath } = await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { const { steps: clearSteps, result: capturePath } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, options?: ?CallServerEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadTask = FileSystem.createUploadTask( url, path, { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; handleUploadFailure(localMessageID: string, localUploadID: string) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { - const report: MediaMissionReportCreationRequest = { + const report: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, id: generateReportID(), }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; editInputMessage = (params: EditInputBarMessageParameters) => { this.editInputBarCallbacks.forEach(addEditInputBarCallback => addEditInputBarCallback(params), ); }; addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks.push(callbackEditInputBar); }; removeEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks = this.editInputBarCallbacks.filter( candidate => candidate !== callbackEditInputBar, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, parentThreadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, threadInfo: ThreadInfo, ) => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { invariant( singleMedia.type === 'photo' || singleMedia.type === 'video', 'Retry selection must be unencrypted', ); let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, localMediaSelection: selection, }; }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; retryMessage = async ( localMessageID: string, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } setPendingThreadUpdateHandler = ( threadID: string, pendingThreadUpdateHandler: ?(ThreadInfo) => mixed, ) => { if (!pendingThreadUpdateHandler) { this.pendingThreadUpdateHandlers.delete(threadID); } else { this.pendingThreadUpdateHandlers.set( threadID, pendingThreadUpdateHandler, ); } }; render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useNextLocalID(); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useSendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThread = useNewThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/native/media/save-media.js b/native/media/save-media.js index 0bb7e4c05..1f97c05c9 100644 --- a/native/media/save-media.js +++ b/native/media/save-media.js @@ -1,467 +1,467 @@ // @flow import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, PermissionsAndroid } from 'react-native'; import filesystem from 'react-native-fs'; import { useDispatch } from 'react-redux'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { readableFilename, pathFromURI } from 'lib/media/file-utils.js'; import { isLocalUploadID } from 'lib/media/media-utils.js'; import type { MediaMissionStep, MediaMissionResult, MediaMissionFailure, } from 'lib/types/media-types.js'; import { reportTypes, - type MediaMissionReportCreationRequest, + type ClientMediaMissionReportCreationRequest, } from 'lib/types/report-types.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { fetchBlob } from './blob-utils.js'; import { fetchAssetInfo, fetchFileInfo, disposeTempFile, mkdir, androidScanFile, fetchFileHash, copyFile, temporaryDirectoryPath, } from './file-utils.js'; import { getMediaLibraryIdentifier } from './identifier-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { requestAndroidPermission } from '../utils/android-permissions.js'; export type IntentionalSaveMedia = ( uri: string, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, ) => Promise; function useIntentionalSaveMedia(): IntentionalSaveMedia { const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); return React.useCallback( async ( uri: string, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, ) => { const start = Date.now(); const steps = [{ step: 'save_media', uri, time: start }]; const { resultPromise, reportPromise } = saveMedia(uri, 'request'); const result = await resultPromise; const userTime = Date.now() - start; let message; if (result.success) { message = 'saved!'; } else if (result.reason === 'save_unsupported') { const os = Platform.select({ ios: 'iOS', android: 'Android', default: Platform.OS, }); message = `saving media is unsupported on ${os}`; } else if (result.reason === 'missing_permission') { message = 'don’t have permission :('; } else if ( result.reason === 'resolve_failed' || result.reason === 'data_uri_failed' ) { message = 'failed to resolve :('; } else if (result.reason === 'fetch_failed') { message = 'failed to download :('; } else { message = 'failed to save :('; } displayActionResultModal(message); if (!mediaReportsEnabled) { return; } const reportSteps = await reportPromise; steps.push(...reportSteps); const totalTime = Date.now() - start; const mediaMission = { steps, result, userTime, totalTime }; const { uploadID, messageServerID, messageLocalID } = ids; const uploadIDIsLocal = isLocalUploadID(uploadID); - const report: MediaMissionReportCreationRequest = { + const report: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: uploadIDIsLocal ? undefined : uploadID, uploadLocalID: uploadIDIsLocal ? uploadID : undefined, messageServerID, messageLocalID, id: generateReportID(), }; dispatch({ type: queueReportsActionType, payload: { reports: [report] }, }); }, [dispatch, mediaReportsEnabled], ); } type Permissions = 'check' | 'request'; function saveMedia( uri: string, permissions?: Permissions = 'check', ): { resultPromise: Promise, reportPromise: Promise<$ReadOnlyArray>, } { let resolveResult; const sendResult = result => { if (resolveResult) { resolveResult(result); } }; const reportPromise = innerSaveMedia(uri, permissions, sendResult); const resultPromise = new Promise(resolve => { resolveResult = resolve; }); return { reportPromise, resultPromise }; } async function innerSaveMedia( uri: string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { if (Platform.OS === 'android') { return await saveMediaAndroid(uri, permissions, sendResult); } else if (Platform.OS === 'ios') { return await saveMediaIOS(uri, sendResult); } else { sendResult({ success: false, reason: 'save_unsupported' }); return []; } } const androidSavePermission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE; // On Android, we save the media to our own Comm folder in the // Pictures directory, and then trigger the media scanner to pick it up async function saveMediaAndroid( inputURI: string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { const steps = []; let hasPermission = false, permissionCheckExceptionMessage; const permissionCheckStart = Date.now(); try { hasPermission = await requestAndroidPermission( androidSavePermission, 'throw', ); } catch (e) { permissionCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'permissions_check', success: hasPermission, exceptionMessage: permissionCheckExceptionMessage, time: Date.now() - permissionCheckStart, platform: Platform.OS, permissions: [androidSavePermission], }); if (!hasPermission) { sendResult({ success: false, reason: 'missing_permission' }); return steps; } const promises = []; let success = true; const saveFolder = `${filesystem.PicturesDirectoryPath}/Comm/`; promises.push( (async () => { const makeDirectoryStep = await mkdir(saveFolder); if (!makeDirectoryStep.success) { success = false; sendResult({ success, reason: 'make_directory_failed' }); } steps.push(makeDirectoryStep); })(), ); let uri = inputURI; let tempFile, mime; if (uri.startsWith('http')) { promises.push( (async () => { const { result: tempSaveResult, steps: tempSaveSteps } = await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { success = false; sendResult(tempSaveResult); } else { tempFile = tempSaveResult.path; uri = `file://${tempFile}`; mime = tempSaveResult.mime; } })(), ); } await Promise.all(promises); if (!success) { return steps; } const { result: copyResult, steps: copySteps } = await copyToSortedDirectory( uri, saveFolder, mime, ); steps.push(...copySteps); if (!copyResult.success) { sendResult(copyResult); return steps; } sendResult({ success: true }); const postResultPromises = []; postResultPromises.push( (async () => { const scanFileStep = await androidScanFile(copyResult.path); steps.push(scanFileStep); })(), ); if (tempFile) { postResultPromises.push( (async (file: string) => { const disposeStep = await disposeTempFile(file); steps.push(disposeStep); })(tempFile), ); } await Promise.all(postResultPromises); return steps; } // On iOS, we save the media to the camera roll async function saveMediaIOS( inputURI: string, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { const steps = []; let uri = inputURI; let tempFile; if (uri.startsWith('http')) { const { result: tempSaveResult, steps: tempSaveSteps } = await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { sendResult(tempSaveResult); return steps; } tempFile = tempSaveResult.path; uri = `file://${tempFile}`; } else if (!uri.startsWith('file://')) { const mediaNativeID = getMediaLibraryIdentifier(uri); if (mediaNativeID) { const { result: fetchAssetInfoResult, steps: fetchAssetInfoSteps } = await fetchAssetInfo(mediaNativeID); steps.push(...fetchAssetInfoSteps); const { localURI } = fetchAssetInfoResult; if (localURI) { uri = localURI; } } } if (!uri.startsWith('file://')) { sendResult({ success: false, reason: 'resolve_failed', uri }); return steps; } let success = false, exceptionMessage; const start = Date.now(); try { await MediaLibrary.saveToLibraryAsync(uri); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'ios_save_to_library', success, exceptionMessage, time: Date.now() - start, uri, }); if (success) { sendResult({ success: true }); } else { sendResult({ success: false, reason: 'save_to_library_failed', uri }); } if (tempFile) { const disposeStep = await disposeTempFile(tempFile); steps.push(disposeStep); } return steps; } type IntermediateSaveResult = { result: { success: true, path: string, mime: string } | MediaMissionFailure, steps: $ReadOnlyArray, }; async function saveRemoteMediaToDisk( inputURI: string, directory: string, // should end with a / ): Promise { const steps = []; const { result: fetchBlobResult, steps: fetchBlobSteps } = await fetchBlob( inputURI, ); steps.push(...fetchBlobSteps); if (!fetchBlobResult.success) { return { result: fetchBlobResult, steps }; } const { mime, base64 } = fetchBlobResult; const tempName = readableFilename('', mime); if (!tempName) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const tempPath = `${directory}tempsave.${tempName}`; const start = Date.now(); let success = false, exceptionMessage; try { await filesystem.writeFile(tempPath, base64, 'base64'); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'write_file', success, exceptionMessage, time: Date.now() - start, path: tempPath, length: base64.length, }); if (!success) { return { result: { success: false, reason: 'write_file_failed' }, steps }; } return { result: { success: true, path: tempPath, mime }, steps }; } async function copyToSortedDirectory( localURI: string, directory: string, // should end with a / inputMIME: ?string, ): Promise { const steps = []; const path = pathFromURI(localURI); if (!path) { return { result: { success: false, reason: 'resolve_failed', uri: localURI }, steps, }; } let mime = inputMIME; const promises = {}; promises.hashStep = fetchFileHash(path); if (!mime) { promises.fileInfoResult = fetchFileInfo(localURI, undefined, { mime: true, }); } const { hashStep, fileInfoResult } = await promiseAll(promises); steps.push(hashStep); if (!hashStep.success) { return { result: { success: false, reason: 'fetch_file_hash_failed' }, steps, }; } const { hash } = hashStep; invariant(hash, 'hash should be truthy if hashStep.success is truthy'); if (fileInfoResult) { steps.push(...fileInfoResult.steps); if (fileInfoResult.result.success && fileInfoResult.result.mime) { ({ mime } = fileInfoResult.result); } } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const name = readableFilename(hash, mime); if (!name) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const newPath = `${directory}${name}`; const copyStep = await copyFile(path, newPath); steps.push(copyStep); if (!copyStep.success) { return { result: { success: false, reason: 'copy_file_failed' }, steps, }; } return { result: { success: true, path: newPath, mime }, steps, }; } export { useIntentionalSaveMedia, saveMedia };