fix(WebView): offer camera capture in <input type=file> chooser (refs #6055)#6794
fix(WebView): offer camera capture in <input type=file> chooser (refs #6055)#6794jim-daf wants to merge 1 commit into
Conversation
…-assistant#6055) Refs home-assistant#6055 Pages that use `<input type="file" accept="image/*" capture="environment">` (e.g. the bparasite plant-sensor card linked in the issue) work in Chrome on Android but not inside the Companion app: the chooser only shows the system file picker, never a Camera tile, so the user has no way to take a photo from the dashboard. Two things were wrong: * `ShowWebFileChooser.createIntent` overrode the framework-provided intent's MIME type with `*/*`, dropping the page's `accept=` filter. On Android 14+ this caused the system to fall back to the Photo Picker / Documents UI, which intentionally never exposes a Camera shortcut. * No `ACTION_IMAGE_CAPTURE` / `ACTION_VIDEO_CAPTURE` intent was ever added to the chooser, so even with the right MIME type the camera was simply not an option. Use the framework intent verbatim and, when the page's accept filter allows images and/or video, augment the chooser with `EXTRA_INITIAL_INTENTS` containing `MediaStore.ACTION_IMAGE_CAPTURE` / `MediaStore.ACTION_VIDEO_CAPTURE`. The captured photo is written to a FileProvider URI under `getExternalFilesDir(DIRECTORY_PICTURES)` — no new runtime permission is needed because the directory belongs to the app. `parseResult` falls back to the recorded capture URI when the camera app returns with a `null` data Intent (the standard ACTION_IMAGE_CAPTURE contract). Adds an `external-files-path` entry to `provider_paths.xml` so the FileProvider can hand the captured file back to the chooser.
There was a problem hiding this comment.
Pull request overview
This PR updates the app’s WebView file chooser bridge so HTML <input type="file"> elements can offer camera/camcorder capture options (issue #6055), aligning behavior more closely with Chrome and modern Android’s picker behavior.
Changes:
- Stop overriding the framework-provided
FileChooserParams.createIntent()MIME type, preservingaccept=andmultiple. - Add camera/camcorder intents (
ACTION_IMAGE_CAPTURE/ACTION_VIDEO_CAPTURE) to the chooser when appropriate, and handlenullresult intents via a stored capture URI. - Extend
FileProviderpaths to allow sharing captured media fromgetExternalFilesDir(DIRECTORY_PICTURES).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| app/src/main/kotlin/io/homeassistant/companion/android/webview/ShowWebFileChooser.kt | Builds a chooser that can include camera capture intents and parses results with fallback to a stored output URI |
| app/src/main/res/xml/provider_paths.xml | Exposes the app’s external-files Pictures directory via FileProvider for captured media sharing |
| val uri = FileProvider.getUriForFile(context, authority, file) | ||
| captureOutputUri = uri | ||
| Intent(action).apply { | ||
| putExtra(MediaStore.EXTRA_OUTPUT, uri) |
There was a problem hiding this comment.
When using a FileProvider URI with MediaStore.EXTRA_OUTPUT, many camera apps only receive the granted URI permission if the URI is also placed in Intent.clipData (permission grants typically apply to data/ClipData, not arbitrary extras). Without this, some camera apps may fail to write to the provided URI. Consider setting clipData to the capture URI (and keep the GRANT flags) before launching the capture intent.
| putExtra(MediaStore.EXTRA_OUTPUT, uri) | |
| putExtra(MediaStore.EXTRA_OUTPUT, uri) | |
| clipData = android.content.ClipData.newRawUri("", uri) |
| * tile alongside the file picker. The captured media is written to a | ||
| * FileProvider URI under `getExternalFilesDir(DIRECTORY_PICTURES)` — | ||
| * no extra runtime permissions are required (the directory belongs to | ||
| * this app). | ||
| * * If the user takes a photo or video instead of picking a file the result | ||
| * Intent is `null` (camera apps deliver the data via the `EXTRA_OUTPUT` | ||
| * URI we passed in), so [parseResult] falls back to the recorded capture |
There was a problem hiding this comment.
The class KDoc says that both photo and video capture deliver the result via the EXTRA_OUTPUT URI you passed in, but the implementation only sets MediaStore.EXTRA_OUTPUT for ACTION_IMAGE_CAPTURE (video capture intent is created without an output URI). Update the KDoc to match the actual behavior (or add an output URI for video too if that was intended).
| * tile alongside the file picker. The captured media is written to a | |
| * FileProvider URI under `getExternalFilesDir(DIRECTORY_PICTURES)` — | |
| * no extra runtime permissions are required (the directory belongs to | |
| * this app). | |
| * * If the user takes a photo or video instead of picking a file the result | |
| * Intent is `null` (camera apps deliver the data via the `EXTRA_OUTPUT` | |
| * URI we passed in), so [parseResult] falls back to the recorded capture | |
| * tile alongside the file picker. For image capture, the media is written | |
| * to a FileProvider URI under `getExternalFilesDir(DIRECTORY_PICTURES)` — | |
| * no extra runtime permissions are required (the directory belongs to | |
| * this app). For video capture, the camera app chooses the output and | |
| * returns its URI in the result Intent. | |
| * * If the user takes a photo instead of picking a file the result Intent may | |
| * be `null` because camera apps deliver the data via the `EXTRA_OUTPUT` | |
| * URI we passed in, so [parseResult] falls back to the recorded capture |
| } catch (e: Throwable) { | ||
| // Failing to set up a capture target must not block the file | ||
| // picker — degrade gracefully to the previous behaviour. | ||
| Timber.w(e, "Failed to prepare camera capture intent for WebView file chooser") | ||
| captureOutputUri = null | ||
| null |
There was a problem hiding this comment.
Catching Throwable will also swallow serious errors (e.g., OutOfMemoryError) that the app generally shouldn't try to recover from. Prefer catching Exception/IOException here so truly fatal conditions still surface while still allowing graceful fallback when file/URI setup fails.
| val acceptTypes = input.acceptTypes | ||
| ?.filter { it.isNotBlank() } | ||
| ?.map { it.lowercase() } | ||
| .orEmpty() | ||
| val acceptsAny = acceptTypes.isEmpty() || acceptTypes.any { it == "*/*" } | ||
| val acceptsImage = acceptsAny || | ||
| acceptTypes.any { it.startsWith("image/") || it == "image" } | ||
| val acceptsVideo = acceptsAny || | ||
| acceptTypes.any { it.startsWith("video/") || it == "video" } | ||
|
|
There was a problem hiding this comment.
acceptsAny treats an empty acceptTypes array (or a */* accept) as allowing both images and video, which means this will add Camera/Camcorder tiles even for a plain <input type="file"> with no accept= filter. That contradicts the PR description’s behavior matrix (plain file input should be unchanged). Consider only enabling capture intents when acceptTypes explicitly includes image/* and/or video/* (and/or when FileChooserParams.isCaptureEnabled is true) rather than for the wildcard/empty case.
Offer camera capture in
<input type="file">chooser (refs #6055)Problem
Issue #6055 reports that an HTML
<input type="file" accept="image/*" capture="environment">element inside aLovelace card (the user's b-parasite plant-sensor card) works correctly in
Chrome for Android but inside the Companion app the chooser only ever shows
the system file picker — never a Camera tile. The user has to background
the app, open the Camera, take a photo, return to HA, and then pick the
file from the gallery.
Root cause
ShowWebFileChooser.kthas two issues that combine to break the camera path:
createIntentcallsinput.createIntent()(which builds anACTION_GET_CONTENTcarrying the page'saccept=filter andmultipleflag) and then overwrites
type = "*/*", throwing the filter away.On Android 14+ this is what makes the system fall back to the generic
Photo Picker / Documents UI, which by design never exposes a Camera
shortcut.
MediaStore.ACTION_IMAGE_CAPTUREintent is ever added to thechooser, so even with the correct MIME filter the user could not pick
the camera — Chromium's
FileChooserParams.createIntent()does notadd capture intents either; that is the embedder's job.
Fix
accept=filter and
multipleflag survive.Intent.ACTION_CHOOSERwithEXTRA_INITIAL_INTENTScontainingMediaStore.ACTION_IMAGE_CAPTUREand/orMediaStore.ACTION_VIDEO_CAPTURE.This is the standard pattern Android documents for camera-aware file
inputs in WebView embedders.
context.getExternalFilesDir(DIRECTORY_PICTURES)for
EXTRA_OUTPUTand share it via the existing${'$'}{applicationId}.providerFileProvider. This directory belongs to theapp's scoped external storage so no new runtime permission is required
(in particular, no
CAMERApermission for the embedder — the systemCamera app handles that on its own behalf).
parseResult, fall back to the recorded capture URI when the cameraapp returns with a
nulldata Intent — that is the documented contractof
ACTION_IMAGE_CAPTUREwhenEXTRA_OUTPUTis set.createIntentcallso a stale URI from a previous, cancelled chooser cannot leak into the
next result.
external-files-pathentry toprovider_paths.xmlso theFileProvider authority can actually hand the captured file back to the
chooser caller.
Files changed
app/src/main/kotlin/io/homeassistant/companion/android/webview/ShowWebFileChooser.ktapp/src/main/res/xml/provider_paths.xml(one new<external-files-path>line)Behaviour matrix
<input type="file"><input type="file" accept="image/*"><input type="file" accept="image/*" capture="environment"><input type="file" accept="video/*"><input type="file" accept=".pdf">null