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

[Feature]: native camera max weight settings #2080

Open
3 tasks done
Aarbel opened this issue Apr 15, 2024 · 0 comments
Open
3 tasks done

[Feature]: native camera max weight settings #2080

Aarbel opened this issue Apr 15, 2024 · 0 comments

Comments

@Aarbel
Copy link

Aarbel commented Apr 15, 2024

Description

Need: to natively include settings to

  • being able to set a max size of pictures taken with capacitor camera
    = don't upload pictures of 200Mo on recent phones, making app crash

Platforms

  • iOS
  • Android
  • Web

Request or proposed solution

When some users use he capacitor camera (phones, tablets, laptops), the browser sometimes crashes due to very high resolution (due to modern high resolution cameras).
We are now handling the pictures compression / resize with javascript, but browser hardly manipulate pictures of 200Mo in a mobile webview, causing app crashes.

Camera plugin has correctOrientation option to have non rotated pictures that works on Android and iOS.
Also has width and height options for the pixel size, the best would also be to define maximum file size (weight) in Mo.

Alternatives

Javascript alternatives are not good, because mobile web browsers most of the time don't have enough RAM to handle this kind of pictures.

Example of code using capacitor camera plugin today (it should me muuuuuch simpler):

import type { Photo } from "@capacitor/camera";
import {
  Camera,
  CameraDirection,
  CameraResultType,
  CameraSource,
} from "@capacitor/camera";
import { Capacitor } from "@capacitor/core";
import { compressImage } from "~/config/browser-image-compression";

enum TakePhotoError {
  PERMISSION_DENIED = "CameraPermissionDenied",
  USER_CANCELED = "UserCanceledPhoto",
  UNEXPECTED = "UnexpectedError",
}

type TakePhotoReturnType =
  | { result: { blob: Blob; image: Photo } }
  | { result: null; error?: { type: TakePhotoError; data?: any } };

const sleep = (milliseconds: number) => {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
};

// Take a picture on native devices, using only HTML standard
// We need this on iOS because capacitor provide uri that we can't
// fetch because of Safari CORS policies.
// An alternative would be to use capacitor/camera base64 image
// but this lead to out of memory error when the picture taken is too big
// (which is often the case with iPhone camera quality)
// The underlying issue with Capacitor camera plugin is that there is no way
// for us to directly retrieve "Blob" data from the native camera API.
// Thanksfully HTML native input with some options allows us to do just that
// even if it's not a perfect way to do this, this is the best we found as today
async function takeNativePicture(): Promise<File | null> {
  // We create our input which we will programatically activate to take our picture
  // Cf: https://developer.mozilla.org/fr/docs/Web/HTML/Attributes/capture
  const input = document.createElement("input");
  let pictureFile: File | null = null;
  input.type = "file";
  // This options will select the rear camera of the device
  input.capture = "environment";
  // Behavior of accept property is not uniform on all devices (for example on IOs it opens a
  // select to choose between camera and gallery, but nos on Android), it should be fixed in the
  // time, cf https://github.com/apache/cordova-android/issues/816#issuecomment-1214221026
  input.accept = "image/*";

  // This is here due to a lack of browser implementation of the latest html standard
  // As today, there is no straightforward way to know if a "file input" pop-up (such as photo)
  // has be closed / canceled / aborted, a way to do so has been added to HTML standard recently but
  // not implemented in any browser yet
  // cf: https://github.com/whatwg/html/issues/6376 and https://github.com/whatwg/html/pull/6735
  // So, as today, we must use dirty tricks to know if our user is still inside the "file input" modal or not
  // Here, I decided to go with the "documentvisibilty" as a singleton to do so. When the photo model is opened
  // on mobile, the document visibility become "hidden", when the modal is closed, it become visible again
  let documentVisibility = null;
  const visibilityChangeHandler = () => {
    documentVisibility = document.visibilityState;
  };
  const inputChangeHandler = (ev: React.FormEvent<HTMLInputElement>) => {
    if (ev.currentTarget.files && ev.currentTarget.files.length > 0) {
      pictureFile = ev.currentTarget.files[0];
    }
  };
  document.addEventListener("visibilitychange", visibilityChangeHandler);
  // We must listen on both events to handle the case when a user re-take a photo
  input.addEventListener(
    "input",
    inputChangeHandler as unknown as EventListener
  );
  input.addEventListener(
    "change",
    inputChangeHandler as unknown as EventListener
  );
  // This will trigger the "photo modal opening" on mobile devices
  input.click();
  // While our user is inside our modal, we want to wait until he either: dismiss it, or take a picture
  // note that we have to check for both status: "null" AND "hidden", indeed afer the input.click, it can
  // take between 300 to 500ms for the phone to open the "photo modal", once he does, the "event listened" will
  // set our "documentVisibility" to hidden accordingly, and once the modal is closed, it'll go to "visible"
  // so we have 3 states:
  //    - documentVisibility is null (our photo modal hasn't been opened yet, wait for it)
  //    - documentVisibility hidden (photo modal is currently displayed to the user)
  //    - documentVisibility visible (modal has been open, and is now closed, we can continue)
  // Also if a file is uploaded in non-mobile/full page context (eg: on desktop), we break our loop if the file input
  // is populated at some point.
  while (
    (documentVisibility === null || documentVisibility === "hidden") &&
    pictureFile === null
  ) {
    await sleep(100);
  }
  // We cleanup our event listener on document visibility as we won't need it anymore
  document.removeEventListener("visibilitychange", visibilityChangeHandler);

  // Sometimes the "visiblitychange" event pop-up before the "input" change has been triggered
  // so if the input is empty, add a last wait to leave the time to the browser to process the input change callback
  if (!pictureFile) {
    await sleep(100);
    pictureFile = input.files?.[0] ?? null;
  }

  // If our user took a picture, we will have it into the files of the input
  return pictureFile;
}

async function takePhoto(): Promise<TakePhotoReturnType> {
  let cameraPermission = { camera: "granted" };
  // On web, Camera.requestPermissions is not implemented, we do it only if we are on a native platform
  if (Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Camera")) {
    cameraPermission = await Camera.requestPermissions({
      permissions: ["camera"],
    });
  }
  if (cameraPermission.camera !== "denied") {
    // This is a dirty trick to avoid the "pwa-camera-modal" pop-up to be blocked by "radix-dialog"
    // modal dialogs. We don't control where the pwa modal is put in the dom
    // So we have to remove the "pointer-events: none" from body if he exist (radix-dialog is open)
    // to allow interactions with the "pwa-camera-modal", then we restore it as it was before the camera
    // was opened, to avoid side effects with our "radix-dialog" logic
    // TODO: find a less dirty way to handle that
    const oldBodyPointerEvents =
      document.body.style.getPropertyValue("pointer-events");

    // Here we restore our pointer-events style on body as it was before taking photo,
    // This MUST be called whatever the result of getPhoto (sucess || error)
    const restoreBodyPointerEvents = () => {
      if (oldBodyPointerEvents) {
        document.body.style.setProperty("pointer-events", oldBodyPointerEvents);
      }
    };

    try {
      if (Capacitor.isNativePlatform()) {
        const file = await takeNativePicture();
        if (file) {
          // We compress the photo because it can cause canvas crash on iOS when annotating
          const compressedFile = await compressImage(file);
          return {
            result: {
              blob: compressedFile,
              image: {
                format: compressedFile.type.replace("image/", ""),
                saved: false,
                webPath: `${Date.now()}${compressedFile.name}`,
              },
            },
          };
        }
        // If we have no file is empty, it means the user canceled the photo popup
        return { error: { type: TakePhotoError.USER_CANCELED }, result: null };
      } else {
        document.body.style.setProperty("pointer-events", "auto");
        const image = await Camera.getPhoto({
          allowEditing: true,
          correctOrientation: false,
          direction: CameraDirection.Rear,
          quality: 90,
          resultType: CameraResultType.Uri,
          source: CameraSource.Camera,
        });
        restoreBodyPointerEvents();
        if (image.webPath) {
          const resp = await fetch(image.webPath);
          const blob = await resp.blob();
          return { result: { blob, image } };
        }
      }
      // This is dirty as fuck but capcitor throw an error
      // for whatever happens in the api other than a full complete "picture taken and validated" logic
    } catch (e: unknown) {
      restoreBodyPointerEvents();
      if (e instanceof Error) {
        if (e?.message?.startsWith?.("User cancelled")) {
          return {
            error: { type: TakePhotoError.USER_CANCELED },
            result: null,
          };
        }
      }
    }
    return { error: { type: TakePhotoError.UNEXPECTED }, result: null };
  }
  return { error: { type: TakePhotoError.PERMISSION_DENIED }, result: null };
}

export { takePhoto, TakePhotoError };

Cf ionic-team/capacitor#7401

@ionitron-bot ionitron-bot bot added the triage label Apr 15, 2024
@ionitron-bot ionitron-bot bot removed the triage label Apr 15, 2024
@Aarbel Aarbel changed the title [Feature]: native camera resolution, rotation and max size settings [Feature]: native camera m, rotation and max size settings Apr 15, 2024
@Aarbel Aarbel changed the title [Feature]: native camera m, rotation and max size settings [Feature]: native camera max size settings Apr 15, 2024
@Aarbel Aarbel changed the title [Feature]: native camera max size settings [Feature]: native camera max weight settings Apr 15, 2024
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

2 participants