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

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

Open
2 of 8 tasks
statico opened this issue Mar 28, 2024 · 11 comments
Open
2 of 8 tasks

Comments

@statico
Copy link

statico commented Mar 28, 2024

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 facebook/react-native#25244 which, luckily references the solution: facebook/react-native#25244 (comment)

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
    • 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:
    • I am using a physical device
    • OS version: Android 14
    • Device/Emulator: Pixel 5a

Additional context

Screenshots

Click To Expand

(see above video)


@khushal87
Copy link
Member

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
Copy link
Author

statico commented Mar 29, 2024

@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
Copy link
Member

khushal87 commented Mar 29, 2024

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
Copy link
Member

@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
Copy link
Member

this issue was fixed in #2334

v5.22.1

@statico
Copy link
Author

statico commented Mar 29, 2024

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
Copy link
Author

statico commented Mar 29, 2024

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
Copy link
Member

santhoshvai commented Apr 2, 2024

@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
Copy link
Author

statico commented Apr 19, 2024

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
Copy link
Member

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
Copy link
Author

statico commented May 22, 2024

Nope, no sendImage customization. :/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants