Skip to content

fix(WebView): offer camera capture in <input type=file> chooser (refs #6055)#6794

Open
jim-daf wants to merge 1 commit into
home-assistant:mainfrom
jim-daf:fix/issue-6055-webview-camera-capture
Open

fix(WebView): offer camera capture in <input type=file> chooser (refs #6055)#6794
jim-daf wants to merge 1 commit into
home-assistant:mainfrom
jim-daf:fix/issue-6055-webview-camera-capture

Conversation

@jim-daf
Copy link
Copy Markdown
Contributor

@jim-daf jim-daf commented Apr 30, 2026

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 a
Lovelace 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.kt
has two issues that combine to break the camera path:

  1. createIntent calls input.createIntent() (which builds an
    ACTION_GET_CONTENT carrying the page's accept= filter and multiple
    flag) 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.
  2. No MediaStore.ACTION_IMAGE_CAPTURE intent is ever added to the
    chooser, so even with the correct MIME filter the user could not pick
    the camera — Chromium's FileChooserParams.createIntent() does not
    add capture intents either; that is the embedder's job.

Fix

  • Use the framework-provided intent verbatim so the page's accept=
    filter and multiple flag survive.
  • When the accept filter allows images and/or video, wrap the chooser in
    Intent.ACTION_CHOOSER with EXTRA_INITIAL_INTENTS containing
    MediaStore.ACTION_IMAGE_CAPTURE and/or MediaStore.ACTION_VIDEO_CAPTURE.
    This is the standard pattern Android documents for camera-aware file
    inputs in WebView embedders.
  • Generate a temp file under context.getExternalFilesDir(DIRECTORY_PICTURES)
    for EXTRA_OUTPUT and share it via the existing
    ${'$'}{applicationId}.provider FileProvider. This directory belongs to the
    app's scoped external storage so no new runtime permission is required
    (in particular, no CAMERA permission for the embedder — the system
    Camera app handles that on its own behalf).
  • In parseResult, fall back to the recorded capture URI when the camera
    app returns with a null data Intent — that is the documented contract
    of ACTION_IMAGE_CAPTURE when EXTRA_OUTPUT is set.
  • Reset the captured-URI member at the start of every createIntent call
    so a stale URI from a previous, cancelled chooser cannot leak into the
    next result.
  • Add an external-files-path entry to provider_paths.xml so the
    FileProvider authority can actually hand the captured file back to the
    chooser caller.

Files changed

  • app/src/main/kotlin/io/homeassistant/companion/android/webview/ShowWebFileChooser.kt
  • app/src/main/res/xml/provider_paths.xml (one new <external-files-path> line)

Behaviour matrix

Page input Before After
<input type="file"> system picker, all types unchanged
<input type="file" accept="image/*"> Photo Picker only Photo Picker and Camera tile
<input type="file" accept="image/*" capture="environment"> Photo Picker only Photo Picker and Camera tile
<input type="file" accept="video/*"> system picker system picker and Camcorder tile
<input type="file" accept=".pdf"> system picker for PDFs unchanged (no capture intents added)
User cancels chooser callback receives null unchanged
User picks file callback receives picked URIs unchanged
User taps Camera tile n/a callback receives the captured photo URI

…-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.
@jim-daf jim-daf marked this pull request as ready for review April 30, 2026 15:21
Copilot AI review requested due to automatic review settings April 30, 2026 15:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, preserving accept= and multiple.
  • Add camera/camcorder intents (ACTION_IMAGE_CAPTURE / ACTION_VIDEO_CAPTURE) to the chooser when appropriate, and handle null result intents via a stored capture URI.
  • Extend FileProvider paths to allow sharing captured media from getExternalFilesDir(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)
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
putExtra(MediaStore.EXTRA_OUTPUT, uri)
putExtra(MediaStore.EXTRA_OUTPUT, uri)
clipData = android.content.ClipData.newRawUri("", uri)

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +43
* 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
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
* 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

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +106
} 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
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +66
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" }

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants