GetStream / stream-chat-react-native

πŸ’¬ React-Native Chat SDK ➜ Stream Chat. Includes a tutorial on building your own chat app experience using React-Native, React-Navigation and Stream
https://getstream.io/chat/sdk/react-native/
Other
960 stars 321 forks source link

[πŸ›] Image upload in message input doesn't work on Android 14 (Pixel 5a) #2465

Closed statico closed 2 months ago

statico commented 6 months ago

Issue

When uploading an image from the message input in this environment, the upload fails:

CleanShot 2024-03-28 at 08 26 41

Stream hides the error, unfortunately. If you run adb logcat you can see this error:

03-27 16:45:19.179  2924  3157 E unknown:Networking: Failed to send url request: https://chat.stream-io-api.com/channels/messaging/xxxxxxxxxxxx/image?user_id=xxxxxxxxxxxxx&connection_id=xxxxxxxxxxxxx&api_key=xxxxxxxx
03-27 16:45:19.179  2924  3157 E unknown:Networking: java.lang.IllegalArgumentException: multipart != application/x-www-form-urlencoded
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at okhttp3.MultipartBody$Builder.setType(MultipartBody.kt:241)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.modules.network.NetworkingModule.constructMultipartBody(NetworkingModule.java:688)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.modules.network.NetworkingModule.sendRequestInternal(NetworkingModule.java:442)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.modules.network.NetworkingModule.sendRequest(NetworkingModule.java:236)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at java.lang.reflect.Method.invoke(Native Method)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:188)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.jni.NativeRunnable.run(Native Method)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at android.os.Handler.handleCallback(Handler.java:958)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at android.os.Handler.dispatchMessage(Handler.java:99)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:27)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at android.os.Looper.loopOnce(Looper.java:205)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at android.os.Looper.loop(Looper.java:294)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:228)
03-27 16:45:19.179  2924  3157 E unknown:Networking:    at java.lang.Thread.run(Thread.java:1012)

(It would be great if the handleFileOrImageUploadError() function in MessageInputContext.tsx showed this error instead of consuming it and hiding it completely.)

The only reference to this appears to be https://github.com/facebook/react-native/issues/25244 which, luckily references the solution: https://github.com/facebook/react-native/issues/25244#issuecomment-1826023980

Adding multipart/form-data to the request header fixed the issue for me.

headers: { 'Content-Type': 'multipart/form-data' }

Here's my solution using Axios interceptors:

const client = StreamChat.getInstance(API_KEY)

// Fix Stream image uploads on Android
client.axiosInstance.interceptors.request.use((request) => {
  if (
    Platform.OS === "android" &&
    request.method === "post" &&
    request.url?.endsWith("/image")
  ) {
    request.headers ||= {}
    request.headers["Content-Type"] = "multipart/form-data"
  }
  return request
})

Steps to reproduce

Steps to reproduce the behavior:

  1. Build an app using Expo 49 and stream-chat-expo
  2. Run the app on Android 14, optionally running adb logcat to see errors
  3. Attempt to upload an image to the chat
  4. See the image upload not work

Expected behavior

The image should be uploaded to the message input and users should be able to send the image to the channel.

Project Related Information

Customization

Click To Expand

- [MessageInputContext.tsx - uploadFile()](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/messageInputContext/MessageInputContext.tsx#L983-L1019) ```tsx const uploadFile = async ({ newFile }: { newFile: FileUpload }) => { const { file, id } = newFile; setFileUploads(getUploadSetStateAction(id, FileState.UPLOADING)); let response: Partial = {}; try { if (value.doDocUploadRequest) { response = await value.doDocUploadRequest(file, channel); } else if (channel && file.uri) { uploadAbortControllerRef.current.set( file.name, client.createAbortControllerForNextRequest(), ); // Compress images selected through file picker when uploading them if (file.mimeType?.includes('image')) { const compressedUri = await compressedImageURI(file, value.compressImageQuality); response = await channel.sendFile(compressedUri, file.name, file.mimeType); } else { response = await channel.sendFile(file.uri, file.name, file.mimeType); } uploadAbortControllerRef.current.delete(file.name); } const extraData: Partial = { thumb_url: response.thumb_url, url: response.file }; setFileUploads(getUploadSetStateAction(id, FileState.UPLOADED, extraData)); } catch (error: unknown) { if ( error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') ) { // nothing to do uploadAbortControllerRef.current.delete(file.name); return; } handleFileOrImageUploadError(error, false, id); } }; const uploadImage = async ({ newImage }: { newImage: ImageUpload }) => { const { file, id } = newImage || {}; if (!file) { return; } let response = {} as SendFileAPIResponse; const uri = file.uri || ''; const filename = file.name ?? uri.replace(/^(file:\/\/|content:\/\/)/, ''); try { const compressedUri = await compressedImageURI(file, value.compressImageQuality); const contentType = lookup(filename) || 'multipart/form-data'; if (value.doImageUploadRequest) { response = await value.doImageUploadRequest(file, channel); } else if (compressedUri && channel) { if (value.sendImageAsync) { uploadAbortControllerRef.current.set( filename, client.createAbortControllerForNextRequest(), ); channel.sendImage(compressedUri, filename, contentType).then( (res) => { uploadAbortControllerRef.current.delete(filename); if (asyncIds.includes(id)) { // Evaluates to true if user hit send before image successfully uploaded setAsyncUploads((prevAsyncUploads) => { prevAsyncUploads[id] = { ...prevAsyncUploads[id], state: FileState.UPLOADED, url: res.file, }; return prevAsyncUploads; }); } else { const newImageUploads = getUploadSetStateAction( id, FileState.UPLOADED, { url: res.file, }, ); setImageUploads(newImageUploads); } }, () => { uploadAbortControllerRef.current.delete(filename); }, ); } else { uploadAbortControllerRef.current.set( filename, client.createAbortControllerForNextRequest(), ); response = await channel.sendImage(compressedUri, filename, contentType); uploadAbortControllerRef.current.delete(filename); } } if (Object.keys(response).length) { const newImageUploads = getUploadSetStateAction(id, FileState.UPLOADED, { height: file.height, url: response.file, width: file.width, }); setImageUploads(newImageUploads); } } catch (error) { if ( error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') ) { // nothing to do uploadAbortControllerRef.current.delete(filename); return; } handleFileOrImageUploadError(error, true, id); } }; ``` - [client.ts - sendFile()](https://github.com/GetStream/stream-chat-js/blob/master/src/client.ts#L1040-L1058) ```tsx sendFile( url: string, uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, contentType?: string, user?: UserResponse, ) { const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); if (user != null) data.append('user', JSON.stringify(user)); return this.doAxiosRequest('postForm', url, data, { headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser config: { timeout: 0, maxContentLength: Infinity, maxBodyLength: Infinity, }, }); } ``` - [client.ts - doAxiosRequest()](https://github.com/GetStream/stream-chat-js/blob/master/src/client.ts#L958) ```tsx doAxiosRequest = async ( type: string, url: string, data?: unknown, options: AxiosRequestConfig & { config?: AxiosRequestConfig & { maxBodyLength?: number }; } = {}, ): Promise => { await this.tokenManager.tokenReady(); const requestConfig = this._enrichAxiosOptions(options); try { let response: AxiosResponse; this._logApiRequest(type, url, data, requestConfig); switch (type) { case 'get': response = await this.axiosInstance.get(url, requestConfig); break; case 'delete': response = await this.axiosInstance.delete(url, requestConfig); break; case 'post': response = await this.axiosInstance.post(url, data, requestConfig); break; case 'postForm': response = await this.axiosInstance.postForm(url, data, requestConfig); break; case 'put': response = await this.axiosInstance.put(url, data, requestConfig); break; case 'patch': response = await this.axiosInstance.patch(url, data, requestConfig); break; case 'options': response = await this.axiosInstance.options(url, requestConfig); break; default: throw new Error('Invalid request type'); } this._logApiResponse(type, url, response); this.consecutiveFailures = 0; return this.handleResponse(response); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any /**TODO: generalize error types */) { e.client_request_id = requestConfig.headers?.['x-client-request-id']; this._logApiError(type, url, e); this.consecutiveFailures += 1; if (e.response) { /** connection_fallback depends on this token expiration logic */ if (e.response.data.code === chatCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) { if (this.consecutiveFailures > 1) { await sleep(retryInterval(this.consecutiveFailures)); } this.tokenManager.loadToken(); return await this.doAxiosRequest(type, url, data, options); } return this.handleResponse(e.response); } else { throw e as AxiosError; } } }; ```

Offline support

Environment

Click To Expand

#### `package.json`: ```json { "dependencies": { "@clerk/clerk-expo": "0.19.16", "@expo/webpack-config": "19.0.0", "@fortawesome/fontawesome-svg-core": "6.4.2", "@fortawesome/free-brands-svg-icons": "6.4.2", "@fortawesome/pro-light-svg-icons": "6.4.2", "@fortawesome/pro-regular-svg-icons": "6.4.2", "@fortawesome/pro-solid-svg-icons": "6.4.2", "@fortawesome/react-native-fontawesome": "0.3.0", "@gorhom/bottom-sheet": "4.5.1", "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", "@react-native-async-storage/async-storage": "1.19.3", "@react-native-community/datetimepicker": "7.6.0", "@react-native-community/netinfo": "9.4.1", "@sentry/react": "7.73.0", "@sentry/react-native": "5.10.0", "@tanstack/react-query": "4.35.7", "@trpc/client": "10.38.5", "@trpc/react-query": "10.38.5", "change-case": "4.1.2", "dotenv": "16.3.1", "expo": "49.0.13", "expo-application": "5.4.0", "expo-auth-session": "5.2.0", "expo-av": "13.6.0", "expo-camera": "13.6.0", "expo-clipboard": "4.5.0", "expo-constants": "14.4.2", "expo-contacts": "12.4.0", "expo-crypto": "12.6.0", "expo-dev-client": "2.4.11", "expo-device": "5.6.0", "expo-document-picker": "11.7.0", "expo-file-system": "15.6.0", "expo-font": "11.6.0", "expo-haptics": "12.6.0", "expo-image": "1.5.1", "expo-image-manipulator": "11.5.0", "expo-image-picker": "14.5.0", "expo-linear-gradient": "~12.3.0", "expo-linking": "5.0.2", "expo-localization": "14.5.0", "expo-location": "16.3.0", "expo-media-library": "15.6.0", "expo-network": "5.6.0", "expo-notifications": "0.20.1", "expo-router": "2.0.8", "expo-secure-store": "12.5.0", "expo-sharing": "11.7.0", "expo-splash-screen": "0.20.5", "expo-status-bar": "1.7.1", "expo-store-review": "6.6.0", "expo-task-manager": "11.5.0", "expo-updates": "0.18.14", "expo-web-browser": "12.5.0", "formik": "2.4.5", "intl-pluralrules": "2.0.1", "json-stringify-safe": "5.0.1", "just-compare": "2.3.0", "libphonenumber-js": "1.10.45", "lodash.debounce": "4.0.8", "luxon": "3.4.3", "metro": "0.79.1", "metro-resolver": "0.79.1", "metro-runtime": "0.79.1", "ms": "2.1.3", "p-retry": "6.1.0", "pluralize": "8.0.0", "posthog-react-native": "2.7.1", "react": "18.2.0", "react-content-loader": "6.2.1", "react-dom": "18.2.0", "react-error-boundary": "4.0.11", "react-native": "0.72.5", "react-native-date-picker": "4.3.3", "react-native-dialog": "9.3.0", "react-native-draggable-flatlist": "4.0.1", "react-native-flex-layout": "0.1.5", "react-native-gesture-handler": "2.13.1", "react-native-keyboard-aware-scroll-view": "0.9.5", "react-native-maps": "1.7.1", "react-native-mmkv": "^2.12.1", "react-native-popup-menu": "0.16.1", "react-native-reanimated": "3.5.4", "react-native-reanimated-confetti": "1.0.1", "react-native-restart": "0.0.27", "react-native-safe-area-context": "4.7.2", "react-native-screens": "3.25.0", "react-native-svg": "13.14.0", "react-native-swipe-list-view": "3.2.9", "react-native-web": "0.19.9", "react-native-web-swiper": "2.2.4", "react-native-webview": "13.6.0", "react-test-renderer": "18.2.0", "recoil": "0.7.7", "rn-range-slider": "2.2.2", "sentry-expo": "7.0.1", "stream-chat-expo": "5.18.1", "swr": "2.2.4", "yup": "1.3.2" }, "devDependencies": { "@babel/core": "7.23.0", "@babel/plugin-transform-flow-strip-types": "7.22.5", "@clerk/types": "3.53.0", "@testing-library/jest-dom": "6.1.3", "@testing-library/jest-native": "5.4.3", "@testing-library/react": "14.0.0", "@testing-library/react-native": "12.3.0", "@types/lodash.debounce": "4.0.7", "@types/ms": "0.7.32", "@types/react": "18.2.24", "@types/react-native": "0.72.3", "@types/webpack-env": "1.18.2", "@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/parser": "6.7.4", "eslint": "8.50.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-import": "2.28.1", "eslint-plugin-react": "7.33.2", "eslint-plugin-simple-import-sort": "10.0.0", "jest-expo": "49.0.0", "knip": "^5.0.2", "typescript": "5.2.2" } } ``` **`react-native info` output:** n/a -- using Expo - **Platform that you're experiencing the issue on**: - [ ] iOS - [x] Android - [ ] **iOS** but have not tested behavior on Android - [ ] **Android** but have not tested behavior on iOS - [ ] Both - **`stream-chat-expo` version you're using that has this issue:** - 5.18.1 and 5.26.0 - Device/Emulator info: - [x] I am using a physical device - OS version: `Android 14` - Device/Emulator: `Pixel 5a`

Additional context

Screenshots

Click To Expand

(see above video)


khushal87 commented 6 months ago

Hey @statico, can you please help me with the version of Axios that is been used in your project? Ideally, we haven't seen this reported yet by any of our customers, but I suspect it is the Axios version that is used in your project that could be leading to this issue for you.

statico commented 6 months ago

@khushal87 Sure:

dependencies:
stream-chat-expo 5.18.1
└─┬ stream-chat-react-native-core 5.18.1
  └─┬ stream-chat 8.12.3
    └── axios 0.22.0

I'll see if i can upgrade the axios transitive dependency and see if that makes a difference.

khushal87 commented 6 months ago

Looking at your Axios version it looks like that's a problem. In stream-chat-js, the client that we use for all the network stuff uses 1.6.0, which in your case is 0.22.0 which could be a culprit.

Also, you are using an old version of stream-chat-expo. Any reasons for that?

santhoshvai commented 6 months ago

@statico you mentioned in the issue that you use stream-chat-expo 5.26.0 but here it seems like you are using 5.18.1. Could you confirm the version please

santhoshvai commented 6 months ago

this issue was fixed in https://github.com/GetStream/stream-chat-react-native/pull/2334

v5.22.1

statico commented 6 months ago

I had tried upgrading to stream-chat-expo 5.26.0 but that didn't have any affect for me. I will try upgrading again as well as verifying the Axios versions.

statico commented 6 months ago

I've confirmed this is still an issue with stream-chat-expo 5.26.0 and axios 1.6.8. I removed all node_modules directories and ran expo start --clean to be sure.

$ pnpm why axios
Legend: production dependency, optional only, dev only

...

dependencies:
stream-chat-expo 5.26.0
└─┬ stream-chat-react-native-core 5.26.0
  └─┬ stream-chat 8.17.0
    └── axios 1.6.8

image

santhoshvai commented 6 months ago

@statico since you are using prnpm here you could be resolving to older axios.. due to the monorepo structure

Could you please give me add this to your metro config before exporting your config and give us what is logged please

I suspect that metro is still resolving to older axios

config.resolver.resolveRequest = (context, moduleName, platform) => {
  const resolved = context.resolveRequest(context, moduleName, platform);
  if (
    moduleName.startsWith('axios') &&
    context.originModulePath.includes('stream-chat')
  ) {
    console.log("axios resolution", { resolved });
  }
  return resolved;
};

module.exports = config;
statico commented 5 months ago

Ah ha! That resolution reported:

axios resolution {
  resolved: {
    type: 'sourceFile',
    filePath: '/Users/ian/dev/xxxx/app/node_modules/axios/index.js'
  }
}

And digging around showed that you're correct, the wrong version is being resolved:

$ cat /Users/ian/dev/xxxx/app/node_modules/axios/lib/env/data.js
module.exports = {
  "version": "0.27.2"
};

pnpm still said I had 1.6.8 installed despite this:

$ pnpm why axios
Legend: production dependency, optional only, dev only

@xxxx/mobile@1.0.0 /Users/ian/dev/xxxx/app/packages/mobile

dependencies:
stream-chat-expo 5.27.1
└─┬ stream-chat-react-native-core 5.27.1
  └─┬ stream-chat 8.17.0
    └── axios 1.6.8

So I explicitly installed Axios within this subproject using pnpm add axios@latest, and now the resolution shows:

axios resolution {
  resolved: {
    type: 'sourceFile',
    filePath: '/Users/ian/dev/xxxx/app/packages/mobile/node_modules/axios/index.js'
  }

And that is definitely a newer version:

$ cat /Users/ian/dev/xxxx/app/packages/mobile/node_modules/axios/lib/env/data.js
export const VERSION = "1.6.8";

However, I did this:

  1. Removed the workaround with Axios interceptors
  2. Deleted all node_modules dirs
  3. Ran pnpm install
  4. Ran expo start --clean
  5. Added an "Axios version" debug widget to our chat screen CleanShot 2024-04-19 at 09 09 53

And I'm still experiencing the bug:

CleanShot 2024-04-19 at 09 07 34

khushal87 commented 4 months ago

Hey @statico, by any chance do you have any customization on the sendImage logic of our SDK in your app? We are not able to reproduce this issue on our environments. May be a reproducible repo would help us facilitate this issue.

statico commented 4 months ago

Nope, no sendImage customization. :/

khushal87 commented 3 months ago

Hey @statico, we were not able to reproduce the problem on our side in the Expo 49 environment. Please provide us with a minimum reproducible repository to facilitate this. Thanks πŸ˜„

khushal87 commented 2 months ago

Closing the issue due to inactivity. Feel free to reopen it if its still relevant with details on how to reproduce it. Thanks πŸ˜„

statico commented 2 months ago

OK. I'm limited on time but I'll reopen this if I'm ever able to make a standalone reproduction.

alex-mironov commented 2 months ago

I'm having the same issue when trying to upload an image. @khushal87 do you have any suggestions on how to fix it?

axios@1.7.2
node_modules/axios
  axios@"^1.6.0" from the root project
  axios@"^1.6.0" from stream-chat@8.37.0
  node_modules/stream-chat
    stream-chat@"^8.37.0" from the root project
    peer stream-chat@"^8.33.1" from stream-chat-react@11.23.3
    node_modules/stream-chat-react
      stream-chat-react@"^11.23.3" from the root project
statico commented 2 months ago

@alex-mironov I have a workaround in the description using Axios interceptors. It's been working for us with that fix since I filed this issue.