fix(upload): let Android guests reach the camera without breaking video (#1244)

* fix(upload): let Android guests reach the camera without breaking video

Recent Android versions route an <input> whose accept list is entirely
image/video types to the system photo picker, which has no camera entry —
so a guest standing at the event can only pick an existing photo, not take
one. Including a type that picker can't handle forces the general chooser,
which does offer the camera.

Two corrections to the original approach in #1117:

- the .pdf is gated on the Android UA. It was appended unconditionally, so
  desktop and iOS pickers — which behave correctly — gained a selectable
  PDF that only produces an error when chosen.
- no image-only guard. #1117 rejected every non-image file before the
  existing allowlist check, which breaks video uploads outright on any
  install configured for them (fileTypes.ts maps mp4/m4v/webm/mov/avi and
  general_allowed_file_types is admin-editable). The guard was also
  redundant: extensionsToMimeTypes only emits types it has a mapping for,
  so application/pdf can never be in allowedMimeTypes and the existing
  "Invalid file type" check already rejects a picked PDF.

The empty-string fallback to 'image/*, .pdf' goes too — extensionsToMimeTypes
already falls back to the configured default set, and image/* was broader
than the admin's allowlist.

Lives in fileTypes.ts as a pure function so the UA behaviour is testable;
the component keeps a one-line useMemo.

Co-authored-by: Zszywany <[email protected]>

* fix(upload): use android/allowCamera instead of .pdf for the chooser fallback

Same mechanism, better token. Chrome on Android 14/15 sends an input whose
accept list is all media types to the photo picker, which has no camera tile;
adding a value that picker cannot satisfy makes it fall back to the general
chooser, which does offer the camera.

`.pdf` achieves that but advertises PDFs as selectable — pick one and the
existing allowlist check answers "Invalid file type", which is a dead end we
put in front of the guest ourselves. `android/allowCamera` is the token the
workaround converged on: not a real MIME type, matches no file, so it flips
the picker without offering anything.

Neither token ever widened what is accepted — addFiles validates against
extensionsToMimeTypes, which only emits types it has a mapping for — but not
showing the guest a choice that cannot work is worth the one-line change.

Verified in a browser rather than asserted: the real component rendered under
an Android UA emits

  image/jpeg,image/png,image/webp,android/allowCamera

and under a desktop UA

  image/jpeg,image/png,image/webp

with the visible modal identical in both, and the format hint still reading
"JPG, JPEG, PNG, WEBP" — the token does not leak into anything a guest sees.

* fix(upload): keep the camera token off Firefox for Android

External review round. The gate was a bare /Android/i, which Firefox for
Android matches — so it received a token invented to reroute Chromium's photo
picker, a picker it does not use. The doc comment two lines up already said
Firefox behaves correctly; the code did not agree with it.

Inert at best, and at worst it perturbs a chooser that was working. Narrowed
to Android minus Firefox, which is the Chromium-family set the behaviour was
actually observed on (Chrome and Edge, Android 14/15), with a UA test to pin
it.

---------

Co-authored-by: Paul Nothaft <[email protected]>
Co-authored-by: Zszywany <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-01 09:24:24 +02:00
committed by GitHub
co-authored by Paul Nothaft Zszywany
parent 7e6bfbecb2
commit 66989d70f1
3 changed files with 84 additions and 3 deletions
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../fileTypes';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel, buildUploadAcceptString } from '../fileTypes';
describe('fileTypes', () => {
describe('extensionsToMimeTypes', () => {
@@ -35,4 +35,46 @@ describe('fileTypes', () => {
expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic');
});
});
describe('buildUploadAcceptString (#1117)', () => {
const ANDROID = 'Mozilla/5.0 (Linux; Android 16; Pixel 9) AppleWebKit/537.36 Chrome/151.0.0.0 Mobile Safari/537.36';
const IOS = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) AppleWebKit/605.1.15 Version/26.0 Mobile Safari/604.1';
const DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/151.0.0.0 Safari/537.36';
const FIREFOX_ANDROID = 'Mozilla/5.0 (Android 16; Mobile; rv:140.0) Gecko/140.0 Firefox/140.0';
it('appends the camera token on Android so the chooser offers the camera', () => {
expect(buildUploadAcceptString('jpg,png', ANDROID)).toBe('image/jpeg,image/png,android/allowCamera');
});
it('adds nothing a guest could actually select', () => {
// The token exists to flip Chrome out of the photo picker, not to widen
// the allowlist. An earlier revision used .pdf, which does flip it but
// also offers PDFs — pick one and you get "Invalid file type".
const accept = buildUploadAcceptString('jpg,png', ANDROID);
expect(accept).not.toMatch(/\.pdf|application\/pdf/);
expect(accept.split(',').filter((t) => t.startsWith('image/') || t.startsWith('video/')))
.toEqual(['image/jpeg', 'image/png']);
});
it('leaves Firefox for Android alone — its chooser already offers the camera', () => {
// The UA says Android, but the behaviour this works around is Chromium's.
expect(buildUploadAcceptString('jpg,png', FIREFOX_ANDROID)).toBe('image/jpeg,image/png');
});
it('leaves iOS and desktop untouched — their pickers already work', () => {
expect(buildUploadAcceptString('jpg,png', IOS)).toBe('image/jpeg,image/png');
expect(buildUploadAcceptString('jpg,png', DESKTOP)).toBe('image/jpeg,image/png');
});
it('keeps offering video when the admin configured it', () => {
// The workaround must not narrow the accept list to images: an install
// with video enabled still has to offer mp4/mov in the chooser.
expect(buildUploadAcceptString('jpg,mp4,mov', ANDROID)).toBe('image/jpeg,video/mp4,video/quicktime,android/allowCamera');
});
it('falls back to the configured default set, not a wider image/*', () => {
expect(buildUploadAcceptString('', DESKTOP)).toBe('image/jpeg,image/png,image/webp');
expect(buildUploadAcceptString('', ANDROID)).toBe('image/jpeg,image/png,image/webp,android/allowCamera');
});
});
});
+37
View File
@@ -52,6 +52,43 @@ export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(',');
}
/**
* `accept` for the guest upload input (#1117).
*
* Chrome and Edge on Android 14/15 route an `<input>` whose accept list is
* entirely image and video types to the system *photo picker*, which has no
* camera tile — so a guest standing at the event can only pick a photo already
* in their gallery, never take one. Adding a value that picker cannot satisfy
* makes Chrome fall back to the general document chooser, which does offer the
* camera.
*
* `android/allowCamera` is the token the workaround converged on. It is not a
* real MIME type and matches no file, which is the point: it flips the picker
* without advertising anything extra as selectable. An earlier revision used
* `.pdf`, which works by the same mechanism but offers PDFs in the chooser —
* pick one and you get "Invalid file type" for your trouble.
*
* Gated to Android MINUS Firefox. The behaviour is Chromium's — Chrome and
* Edge on Android 14/15 — and Firefox for Android, whose UA also says
* `Android`, opens a chooser that already offers the camera. Handing it a
* token invented to reroute a picker it does not use is at best inert and at
* worst changes a chooser that was working.
*
* UA sniffing is the wrong tool in general, but there is no feature query for
* "which picker will this open", and the failure mode of a wrong guess is an
* accept token the browser ignores.
*
* Neither token widens what is actually accepted: `addFiles` validates every
* file against `extensionsToMimeTypes`, which only ever emits types it has a
* mapping for, so nothing new can get past it.
*/
export function buildUploadAcceptString(extString?: string | null, userAgent?: string): string {
const accept = extensionsToAcceptString(extString);
const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
const needsCameraToken = /Android/i.test(ua) && !/Firefox/i.test(ua);
return needsCameraToken ? `${accept},android/allowCamera` : accept;
}
/**
* Human-readable, de-duplicated list of the configured extensions for the
* upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the