Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement file sharing from cozy-stack to external apps #1088

Merged
merged 10 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .storybook/storybook.requires.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ try {

const getStories = () => {
return {
"./src/ui/LoadingOverlay/LoadingOverlay.stories.tsx": require("../src/ui/LoadingOverlay/LoadingOverlay.stories.tsx"),
"./src/ui/Typography/Typography.stories.tsx": require("../src/ui/Typography/Typography.stories.tsx"),
};
};
Expand Down
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ android {

defaultConfig {
// applicationId is set in ./brand.gradle
// if absent use `yarn configure:brand:cozy` to create it
// if absent use `yarn brand:configure:cozy` to create it
namespace = "io.cozy.flagship.mobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- required for react-native-share base64 sharing -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
Expand Down
18 changes: 15 additions & 3 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ PODS:
- React
- react-native-biometrics (3.0.1):
- React-Core
- react-native-blur (4.3.2):
- React-Core
- react-native-cameraroll (7.2.0):
- React-Core
- react-native-change-icon (4.0.0):
Expand Down Expand Up @@ -551,6 +553,8 @@ PODS:
- RNSentry (3.4.3):
- React-Core
- Sentry (= 7.11.0)
- RNShare (10.0.2):
- React-Core
- RNSVG (13.14.0):
- React-Core
- Sentry (7.11.0):
Expand Down Expand Up @@ -607,6 +611,7 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- react-native-background-upload (from `../node_modules/react-native-background-upload`)
- react-native-biometrics (from `../node_modules/react-native-biometrics`)
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
- react-native-change-icon (from `../node_modules/react-native-change-icon`)
- react-native-config (from `../node_modules/react-native-config`)
Expand Down Expand Up @@ -653,6 +658,7 @@ DEPENDENCIES:
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNScreens (from `../node_modules/react-native-screens`)
- "RNSentry (from `../node_modules/@sentry/react-native`)"
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)

Expand Down Expand Up @@ -738,6 +744,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-background-upload"
react-native-biometrics:
:path: "../node_modules/react-native-biometrics"
react-native-blur:
:path: "../node_modules/@react-native-community/blur"
react-native-cameraroll:
:path: "../node_modules/@react-native-camera-roll/camera-roll"
react-native-change-icon:
Expand Down Expand Up @@ -830,16 +838,18 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-screens"
RNSentry:
:path: "../node_modules/@sentry/react-native"
RNShare:
:path: "../node_modules/react-native-share"
RNSVG:
:path: "../node_modules/react-native-svg"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
boost: 57d2868c099736d80fcd648bf211b4431e51a558
boost: a7c83b31436843459a1961bfd74b96033dc77234
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
FBLazyVector: e5569e42a1c79ca00521846c223173a57aca1fe1
FBReactNativeSpec: fe08c1cd7e2e205718d77ad14b34957cce949b58
Firebase: 5f8193dff4b5b7c5d5ef72ae54bb76c08e2b841d
Expand All @@ -858,7 +868,7 @@ SPEC CHECKSUMS:
FlipperKit: d8d346844eca5d9120c17d441a2f38596e8ed2b9
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
glog: 5337263514dd6f09803962437687240c5dc39aa4
GoogleDataTransport: ea169759df570f4e37bdee1623ec32a7e64e67c4
GoogleMLKit: 755661c46990a85e42278015f26400286d98ad95
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
Expand Down Expand Up @@ -892,6 +902,7 @@ SPEC CHECKSUMS:
React-logger: 933f80c97c633ee8965d609876848148e3fef438
react-native-background-upload: 7c608537f87106c93530a3a19a853afd55466823
react-native-biometrics: 352e5a794bfffc46a0c86725ea7dc62deb085bdc
react-native-blur: cfdad7b3c01d725ab62a8a729f42ea463998afa2
react-native-cameraroll: 2a198b0eb86c7ccf3303fb57702b2465cc292c94
react-native-change-icon: ea9bb7255b09e89f41cbf282df16eade91ab1833
react-native-config: 5330c8258265c1e5fdb8c009d2cabd6badd96727
Expand Down Expand Up @@ -938,6 +949,7 @@ SPEC CHECKSUMS:
RNPermissions: 83168e00be08bd043f51fb738256bf761f38952d
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSentry: 85f6525b5fe8d2ada065858026b338605b3c09da
RNShare: 859ff710211285676b0bcedd156c12437ea1d564
RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396
Sentry: 0c5cd63d714187b4a39c331c1f0eb04ba7868341
Yoga: e7dc4e71caba6472ff48ad7d234389b91dadc280
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@notifee/react-native": "^7.8.0",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-native-camera-roll/camera-roll": "^7.2.0",
"@react-native-community/blur": "4.3.2",
Ldoppea marked this conversation as resolved.
Show resolved Hide resolved
"@react-native-community/netinfo": "9.3.7",
"@react-native-cookies/cookies": "^6.0.4",
"@react-native-firebase/app": "^14.12.0",
Expand Down Expand Up @@ -99,6 +100,7 @@
"react-native-print": "0.11.0",
"react-native-safe-area-context": "^4.5.0",
"react-native-screens": "3.18.2",
"react-native-share": "10.0.2",
"react-native-svg": "13.14.0",
"react-native-toast-message": "2.1.6",
"react-native-url-polyfill": "^1.3.0",
Expand Down
5 changes: 5 additions & 0 deletions src/app/domain/osReceive/models/OsReceiveCozyApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ export const fetchOsReceiveCozyApps = {
as: 'io.cozy.apps/accept_from_flagship'
}
}

export interface FileMetadata {
url: string
name: string
}
8 changes: 7 additions & 1 deletion src/app/domain/osReceive/models/OsReceiveState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export enum OsReceiveActionType {
SetRecoveryState = 'SET_RECOVERY_STATE',
SetInitialState = 'SET_INITIAL_STATE',
UpdateFileStatus = 'UPDATE_FILE_STATUS',
SetCandidateApps = 'SET_CANDIDATE_APPS'
SetCandidateApps = 'SET_CANDIDATE_APPS',
SetFilesToShare = 'SET_FILES_TO_SHARE'
}

export enum OsReceiveFileStatus {
Expand All @@ -20,6 +21,8 @@ export enum OsReceiveFileStatus {
queued
}

type FileToShare = string

export interface OsReceiveFile {
name: string
file: ReceivedFile & { fromFlagship: true }
Expand All @@ -34,6 +37,7 @@ export interface OsReceiveState {
routeToUpload: { href?: string; slug?: string }
errored: boolean
candidateApps?: AcceptFromFlagshipManifest[]
filesToShare: FileToShare[]
}

export type OsReceiveAction =
Expand All @@ -53,6 +57,7 @@ export type OsReceiveAction =
type: OsReceiveActionType.SetCandidateApps
payload: AcceptFromFlagshipManifest[]
}
| { type: OsReceiveActionType.SetFilesToShare; payload: string[] }

export interface ServiceResponse<T> {
result?: T
Expand All @@ -68,6 +73,7 @@ export interface OsReceiveApiMethods {
uploadFiles: (arg: string) => boolean
resetFilesToHandle: () => Promise<boolean>
cancelUploadByCozyApp: () => boolean
shareFiles: (arg: string) => void
}

export interface UploadStatus {
Expand Down
14 changes: 13 additions & 1 deletion src/app/domain/osReceive/services/OsReceiveApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ export const sendMessageForFile = async (
}
}

const shareFiles = (arg: string, dispatch: Dispatch<OsReceiveAction>): void => {
try {
const payload = JSON.parse(arg) as string[]
OsReceiveLogger.info(`shareFiles called with arg: ${arg}`)

dispatch({ type: OsReceiveActionType.SetFilesToShare, payload })
} catch (error) {
OsReceiveLogger.error('shareFiles: error', error)
}
}

export const OsReceiveApi = (
client: CozyClient,
state: OsReceiveState,
Expand All @@ -250,5 +261,6 @@ export const OsReceiveApi = (
getFilesToHandle: (base64 = false) => getFilesToHandle(base64, state),
uploadFiles: arg => uploadFiles(arg, state, client, dispatch),
resetFilesToHandle: () => resetFilesToHandle(dispatch),
cancelUploadByCozyApp: () => cancelUploadByCozyApp(dispatch)
cancelUploadByCozyApp: () => cancelUploadByCozyApp(dispatch),
shareFiles: arg => shareFiles(arg, dispatch)
})
161 changes: 161 additions & 0 deletions src/app/domain/osReceive/services/shareFilesService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type CozyClient from 'cozy-client'

import RNFS from 'react-native-fs'
import Share from 'react-native-share'

import { fetchFilesByIds } from '/app/domain/osReceive/services/shareFilesService'

jest.mock('react-native-fs', () => {
return {
DocumentDirectoryPath: '/mockedPath/to/download',
downloadFile: jest.fn().mockImplementation(() => ({
promise: Promise.resolve({
jobId: 1,
statusCode: 200,
bytesWritten: 100,
path: () => 'mocked-file-path'
})
}))
}
})

jest.mock('react-native-share', () => ({
open: jest.fn()
}))

const mockRNFS = RNFS as jest.Mocked<typeof RNFS>
const mockFetchJSON = jest.fn()
const mockCozyClient = {
getStackClient: jest.fn(() => ({
uri: 'http://mocked-uri',
fetchJSON: mockFetchJSON,
token: { accessToken: 'mocked-access-token' }
}))
} as unknown as CozyClient

const mockSuccessfulMetadataResponse = (
fileId: string
): {
data: {
attributes: {
name: string
}
}
} => ({
data: { attributes: { name: `${fileId}-name.pdf` } }
})

describe('fetchFilesByIds', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('should successfully fetch file metadata, download files, and share them', async () => {
const fileIds = ['123', '456']

fileIds.forEach(fileId =>
mockFetchJSON.mockResolvedValueOnce(
mockSuccessfulMetadataResponse(fileId)
)
)

// @ts-expect-error: RNFS is mocked
mockRNFS.downloadFile.mockImplementation(() => ({
promise: Promise.resolve({
jobId: 1,
statusCode: 200,
bytesWritten: 100,
path: () => 'mocked-file-path'
})
}))

await fetchFilesByIds(mockCozyClient, fileIds)

expect(mockCozyClient.getStackClient().fetchJSON).toHaveBeenCalledTimes(
fileIds.length
)
expect(mockRNFS.downloadFile).toHaveBeenCalledTimes(fileIds.length)
expect(Share.open).toHaveBeenCalledWith({
urls: [
'file:///mockedPath/to/download/123-name.pdf',
'file:///mockedPath/to/download/456-name.pdf'
]
})
})

it('should handle errors in fetching file metadata', async () => {
mockFetchJSON.mockRejectedValueOnce(new Error('Metadata Fetch Error'))

await expect(fetchFilesByIds(mockCozyClient, ['123'])).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.any(Error)
)

expect(Share.open).not.toHaveBeenCalled()
})

it('should handle errors in fetching one file metadata among multiple', async () => {
mockFetchJSON.mockResolvedValueOnce(mockSuccessfulMetadataResponse('123'))
mockFetchJSON.mockRejectedValueOnce(new Error('Metadata Fetch Error'))

await expect(
fetchFilesByIds(mockCozyClient, ['123', '456'])
).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.any(Error)
)

expect(Share.open).not.toHaveBeenCalled()
})

it('should handle errors in downloading a file', async () => {
mockFetchJSON.mockResolvedValueOnce(mockSuccessfulMetadataResponse('123'))

// @ts-expect-error: RNFS is mocked
mockRNFS.downloadFile.mockImplementation(() => ({
promise: Promise.resolve({
jobId: 1,
statusCode: 404,
bytesWritten: 100,
path: () => 'mocked-file-path'
})
}))

await expect(fetchFilesByIds(mockCozyClient, ['123'])).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.any(Error)
)

expect(Share.open).not.toHaveBeenCalled()
})

it('should handle errors in downloading one file among multiple', async () => {
const fileIds = ['123', '456']

fileIds.forEach(fileId =>
mockFetchJSON.mockResolvedValueOnce(
mockSuccessfulMetadataResponse(fileId)
)
)

// @ts-expect-error: RNFS is mocked
mockRNFS.downloadFile.mockImplementationOnce(() => ({
promise: Promise.resolve({
jobId: 1,
statusCode: 404,
bytesWritten: 100,
path: () => 'mocked-file-path'
})
}))

await expect(
fetchFilesByIds(mockCozyClient, ['123', '456'])
).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.any(Error)
)

expect(Share.open).not.toHaveBeenCalled()
})
})
Loading
Loading