stream-chat-react-native icon indicating copy to clipboard operation
stream-chat-react-native copied to clipboard

[🐛] Image upload in message input doesn't work on Android 14 (Pixel 5a)

Open statico opened this issue 1 year ago • 11 comments

Issue

  • Pixel 5a running Android 14
  • Expo v49
  • stream-chat-expo 5.26.0

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

  const uploadFile = async ({ newFile }: { newFile: FileUpload }) => {
    const { file, id } = newFile;

    setFileUploads(getUploadSetStateAction(id, FileState.UPLOADING));

    let response: Partial<SendFileAPIResponse> = {};
    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<FileUpload> = { 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<ImageUpload>(
                  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<ImageUpload>(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);
    }
  };
  sendFile(
    url: string,
    uri: string | NodeJS.ReadableStream | Buffer | File,
    name?: string,
    contentType?: string,
    user?: UserResponse<StreamChatGenerics>,
  ) {
    const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
    if (user != null) data.append('user', JSON.stringify(user));

    return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, {
      headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser
      config: {
        timeout: 0,
        maxContentLength: Infinity,
        maxBodyLength: Infinity,
      },
    });
  }
  doAxiosRequest = async <T>(
    type: string,
    url: string,
    data?: unknown,
    options: AxiosRequestConfig & {
      config?: AxiosRequestConfig & { maxBodyLength?: number };
    } = {},
  ): Promise<T> => {
    await this.tokenManager.tokenReady();
    const requestConfig = this._enrichAxiosOptions(options);
    try {
      let response: AxiosResponse<T>;
      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<T>(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<T>(type, url, data, options);
        }
        return this.handleResponse(e.response);
      } else {
        throw e as AxiosError<APIErrorResponse>;
      }
    }
  };

Offline support

  • [ ] I have enabled offline support.
  • [ ] The feature I'm having does not occur when offline support is disabled. (stripe out if not applicable)

Environment

Click To Expand

package.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)


statico avatar Mar 28 '24 16:03 statico

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.

khushal87 avatar Mar 29 '24 13:03 khushal87

@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.

statico avatar Mar 29 '24 15:03 statico

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?

khushal87 avatar Mar 29 '24 15:03 khushal87

@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 avatar Mar 29 '24 15:03 santhoshvai

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

v5.22.1

santhoshvai avatar Mar 29 '24 15:03 santhoshvai

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 avatar Mar 29 '24 15:03 statico

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

statico avatar Mar 29 '24 17:03 statico

@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;

santhoshvai avatar Apr 02 '24 12:04 santhoshvai

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/[email protected] /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

statico avatar Apr 19 '24 16:04 statico

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.

khushal87 avatar May 22 '24 06:05 khushal87

Nope, no sendImage customization. :/

statico avatar May 22 '24 18:05 statico

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 avatar Jun 14 '24 07:06 khushal87

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

khushal87 avatar Jul 02 '24 07:07 khushal87

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

statico avatar Jul 06 '24 20:07 statico

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

[email protected]
node_modules/axios
  axios@"^1.6.0" from the root project
  axios@"^1.6.0" from [email protected]
  node_modules/stream-chat
    stream-chat@"^8.37.0" from the root project
    peer stream-chat@"^8.33.1" from [email protected]
    node_modules/stream-chat-react
      stream-chat-react@"^11.23.3" from the root project

alex-mironov avatar Jul 24 '24 10:07 alex-mironov

@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.

statico avatar Jul 25 '24 16:07 statico