mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(browser): offer the camera for HTML file inputs
Completes the file-input support: a page that accepts photos or video can now reach the camera, not only files already on the device. `accept="image/*"` on a mobile browser means "take one or pick one"; until now Amethyst could only do the second half, which is the wrong half for the common case of uploading a photo. How it decides, mirroring a mobile browser: a bare file input offers stills and video, an image-only accept offers just the camera, a document accept offers neither. Resolved by FileChooserAccept.captureMedia, pure and unit-tested. Unlike the type filter this does NOT widen on an extension the platform cannot name — widening there would put a camera in front of a page that never asked for one. Permission handling is the part worth reading. ACTION_IMAGE_CAPTURE throws SecurityException for an app that declares CAMERA without holding it, and Amethyst declares it, so the grant has to exist before the chooser is built. When the page set `capture` the permission is requested first — the user tapped a control whose entire purpose is to take a photo. Without `capture` the camera is offered only if permission is already held, so opening a document upload never raises a camera prompt out of nowhere. A denial is not a failure: the picker still opens, minus the camera. A camera needs somewhere to put a full-resolution shot (EXTRA_OUTPUT; without one it returns a thumbnail, useless as an upload), so each option gets an empty scratch file in cacheDir behind its own FileProvider — a dedicated one with its own authority and paths file, exposing a single subdirectory rather than the everything the app's general-purpose provider exposes. It needs its own subclass because the manifest merger keys providers by android:name and would otherwise collide with the app's. A chooser entry supplied via EXTRA_INITIAL_INTENTS is started by the system, not by us, and the URI grant flags on it are not reliably carried across that hop, so every resolved camera package is granted write access up front — none of them can be ruled out before the user chooses. That grant is taken back the moment the outcome is known, for the kept capture as well as the discarded ones, revoked per package rather than per URI so it cannot clip this app's own read of its own provider. Unfilled scratch files are deleted immediately; a kept one cannot be (the page may not read it until the form is submitted) and is swept on a later request instead. The three Activity-owning surfaces — the full-screen browser, the full-screen napplet/nSite sandbox, and the main-process host that serves both embedded surfaces — now share one WebFileChooserLauncher, so filtering, multi-select, capture and the permission flow cannot drift between them. The embedded providers pass the input's `capture` flag across the existing Messenger contract rather than having the main process re-derive it. Every path still ends in exactly one call to the page's filePathCallback, including a denied permission, a dismissed camera, and a device with no camera app at all. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
This commit is contained in:
@@ -588,6 +588,7 @@
|
||||
android:name=".napplet.WebFileChooserActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
|
||||
+21
-28
@@ -21,28 +21,26 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletFileChooser
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
import com.vitorpamplona.amethyst.napplethost.WebFileChooserLauncher
|
||||
|
||||
/**
|
||||
* Invisible main-process host for one file pick made on behalf of an **embedded** WebView surface.
|
||||
*
|
||||
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker
|
||||
* from, so [WebFileChooserCoordinator] launches this instead. It exists only long enough to run the
|
||||
* picker and report the result, and it reports on every exit — a chosen file, a cancel, a system
|
||||
* teardown — because the page's `<input type="file">` stays busy until it hears something back.
|
||||
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker or
|
||||
* a permission prompt from, so [WebFileChooserCoordinator] launches this instead. It exists only long
|
||||
* enough to run the pick and report the result, and it reports on every exit — a chosen file, a
|
||||
* cancel, a system teardown — because the page's `<input type="file">` stays busy until it hears
|
||||
* something back.
|
||||
*/
|
||||
class WebFileChooserActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var reported = false
|
||||
|
||||
private val picker =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
report(NappletFileChooser.parseResult(result.resultCode, result.data)?.map { it.toString() }?.toTypedArray())
|
||||
// Field, not a local: registerForActivityResult must run before this activity reaches STARTED.
|
||||
private val chooser =
|
||||
WebFileChooserLauncher(this) { uris ->
|
||||
report(uris?.map { it.toString() }?.toTypedArray())
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -51,26 +49,25 @@ class WebFileChooserActivity : ComponentActivity() {
|
||||
|
||||
val token = intent.getStringExtra(WebFileChooserCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val chooser = token?.let { WebFileChooserCoordinator.chooserFor(it) }
|
||||
if (chooser == null) {
|
||||
// No pending request under this token: the surface went away, or the process was restarted
|
||||
// and the request died with it. Nothing to report to.
|
||||
val ask = token?.let { WebFileChooserCoordinator.pendingFor(it) }
|
||||
if (ask == null) {
|
||||
// No pending request under this token: the surface went away, or the process restarted and
|
||||
// the request died with it. Nothing to report to.
|
||||
reported = true
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// A recreated instance (rotation) already has its pick in flight; re-launching would stack a
|
||||
// A recreated instance (rotation) already has its pick in flight; launching again would stack a
|
||||
// second picker on top of the first.
|
||||
if (savedInstanceState != null) return
|
||||
|
||||
runCatching { picker.launch(chooser) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "No activity available to pick a file", e)
|
||||
Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
|
||||
report(null)
|
||||
finish()
|
||||
}
|
||||
chooser.launch(
|
||||
acceptTypes = ask.acceptTypes,
|
||||
allowMultiple = ask.allowMultiple,
|
||||
captureEnabled = ask.captureEnabled,
|
||||
pageTitle = ask.pageTitle,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fail-open toward the page: any unreported teardown still releases its file input. */
|
||||
@@ -84,8 +81,4 @@ class WebFileChooserActivity : ComponentActivity() {
|
||||
reported = true
|
||||
token?.let { WebFileChooserCoordinator.complete(it, uris) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "WebFileChooser"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.napplet
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletFileChooser
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -34,9 +33,9 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* and [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]) host their
|
||||
* WebView in the keyless `:napplet` process as a windowless Service, so when a page taps
|
||||
* `<input type="file">` there is no Activity there to start a picker from. They send the *description*
|
||||
* of the request over Messenger instead; this builds the Intent here in the main process, launches
|
||||
* [WebFileChooserActivity] to collect the result, and hands the picked URIs back to the caller, which
|
||||
* relays them to the sandbox.
|
||||
* of the request over Messenger instead; this holds it here in the main process and launches
|
||||
* [WebFileChooserActivity], which runs the picker (and the camera, and the CAMERA permission prompt
|
||||
* that a `capture` input needs) and reports back to the caller, which relays the URIs to the sandbox.
|
||||
*
|
||||
* Mirrors [NappletConsentCoordinator]: the pending request is keyed by a one-time token so the
|
||||
* throwaway Activity carries nothing but that token. Every request completes exactly once — a
|
||||
@@ -46,27 +45,32 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* WebView in `:napplet` without any re-granting.
|
||||
*/
|
||||
object WebFileChooserCoordinator {
|
||||
private class Pending(
|
||||
val chooser: Intent,
|
||||
/** The request as it arrived from the sandbox, plus where to send the answer. */
|
||||
class Pending(
|
||||
val acceptTypes: List<String>,
|
||||
val allowMultiple: Boolean,
|
||||
val captureEnabled: Boolean,
|
||||
val pageTitle: String?,
|
||||
val onResult: (Array<String>?) -> Unit,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
/**
|
||||
* Shows a picker filtered by [acceptTypes] (raw HTML `accept` entries) and calls [onResult] with the
|
||||
* picked URIs as strings, or null when the user cancelled. [onResult] always runs, including when no
|
||||
* picker could be started at all — the page is waiting on it.
|
||||
* Shows a picker for [acceptTypes] and calls [onResult] with the picked URIs as strings, or null
|
||||
* when nothing was chosen. [onResult] always runs, including when no picker host could be started
|
||||
* at all — the page's file input is waiting on it.
|
||||
*/
|
||||
fun request(
|
||||
context: Context,
|
||||
acceptTypes: List<String>,
|
||||
allowMultiple: Boolean,
|
||||
captureEnabled: Boolean,
|
||||
pageTitle: String?,
|
||||
onResult: (Array<String>?) -> Unit,
|
||||
) {
|
||||
val token = UUID.randomUUID().toString()
|
||||
pending[token] = Pending(NappletFileChooser.buildIntent(context, acceptTypes, allowMultiple, pageTitle), onResult)
|
||||
pending[token] = Pending(acceptTypes, allowMultiple, captureEnabled, pageTitle, onResult)
|
||||
|
||||
val launch =
|
||||
Intent(context, WebFileChooserActivity::class.java)
|
||||
@@ -80,10 +84,10 @@ object WebFileChooserCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [WebFileChooserActivity] to get the picker it should launch. */
|
||||
fun chooserFor(token: String): Intent? = pending[token]?.chooser
|
||||
/** Called by [WebFileChooserActivity] to learn what to ask the user for. */
|
||||
fun pendingFor(token: String): Pending? = pending[token]
|
||||
|
||||
/** Called by [WebFileChooserActivity] with the outcome; null = cancelled. Resolves at most once. */
|
||||
/** Called by [WebFileChooserActivity] with the outcome; null = nothing chosen. Resolves at most once. */
|
||||
fun complete(
|
||||
token: String,
|
||||
uris: Array<String>?,
|
||||
|
||||
+1
@@ -265,6 +265,7 @@ class EmbeddedWebAppController(
|
||||
context = appContext,
|
||||
acceptTypes = data.getStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_ACCEPT)?.toList().orEmpty(),
|
||||
allowMultiple = data.getBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_MULTIPLE, false),
|
||||
captureEnabled = data.getBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_CAPTURE, false),
|
||||
pageTitle = data.getString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE),
|
||||
) { uris ->
|
||||
send(NappletBrowserContract.MSG_FILE_CHOOSER_RESULT) {
|
||||
|
||||
+1
@@ -257,6 +257,7 @@ class EmbeddedNostrAppController(
|
||||
context = appContext,
|
||||
acceptTypes = data.getStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_ACCEPT)?.toList().orEmpty(),
|
||||
allowMultiple = data.getBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_MULTIPLE, false),
|
||||
captureEnabled = data.getBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_CAPTURE, false),
|
||||
pageTitle = data.getString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE),
|
||||
) { uris ->
|
||||
send(NappletEmbedContract.MSG_FILE_CHOOSER_RESULT) {
|
||||
|
||||
+40
@@ -84,6 +84,46 @@ object FileChooserAccept {
|
||||
return Resolved(primaryType = commonType(mimes), mimeTypes = mimes)
|
||||
}
|
||||
|
||||
/** A camera a file input can plausibly be satisfied by. */
|
||||
enum class CaptureMedia {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cameras to offer for an input accepting [acceptTypes], mirroring what a mobile browser
|
||||
* shows: a bare `<input type="file">` offers both stills and video, an image-only accept offers
|
||||
* just the camera, and `accept="application/pdf"` offers neither.
|
||||
*
|
||||
* Unlike [resolve], an extension the platform can't name contributes nothing instead of widening —
|
||||
* widening here would put a camera in front of a page that never asked for one. [extensionToMime]
|
||||
* is the same lookup [resolve] takes.
|
||||
*/
|
||||
fun captureMedia(
|
||||
acceptTypes: List<String>,
|
||||
extensionToMime: (String) -> String?,
|
||||
): Set<CaptureMedia> {
|
||||
val tokens =
|
||||
acceptTypes
|
||||
.flatMap { it.split(',') }
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
// No accept at all: the page takes anything, so both cameras are fair game.
|
||||
if (tokens.isEmpty()) return setOf(CaptureMedia.IMAGE, CaptureMedia.VIDEO)
|
||||
|
||||
val media = mutableSetOf<CaptureMedia>()
|
||||
for (token in tokens) {
|
||||
if (token == ANY) return setOf(CaptureMedia.IMAGE, CaptureMedia.VIDEO)
|
||||
val mime = if (token.contains('/')) token else extensionToMime(token.removePrefix("."))
|
||||
when (mime?.substringBefore('/')) {
|
||||
"image" -> media.add(CaptureMedia.IMAGE)
|
||||
"video" -> media.add(CaptureMedia.VIDEO)
|
||||
}
|
||||
}
|
||||
return media
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrowest single type covering [mimes]: the type itself when there is only one, the shared
|
||||
* family's wildcard when they all belong to one family, and [ANY] when they span families (or the
|
||||
|
||||
+39
@@ -126,6 +126,45 @@ class FileChooserAcceptTest {
|
||||
assertEquals(listOf("image/jpeg"), resolved.mimeTypes)
|
||||
}
|
||||
|
||||
private fun capture(vararg accept: String) = FileChooserAccept.captureMedia(accept.toList(), map::get)
|
||||
|
||||
@Test
|
||||
fun bareFileInputOffersBothCameras() {
|
||||
// A page that takes anything: a mobile browser offers stills and video there.
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.IMAGE, FileChooserAccept.CaptureMedia.VIDEO), capture())
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.IMAGE, FileChooserAccept.CaptureMedia.VIDEO), capture("*/*"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageAcceptOffersOnlyTheCamera() {
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.IMAGE), capture("image/png"))
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.IMAGE), capture(".jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoAcceptOffersOnlyTheCamcorder() {
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.VIDEO), capture(".mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothMediaOfferBothCameras() {
|
||||
assertEquals(setOf(FileChooserAccept.CaptureMedia.IMAGE, FileChooserAccept.CaptureMedia.VIDEO), capture("image/png", "video/mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun documentAcceptOffersNoCamera() {
|
||||
// Opening a PDF upload must never put a camera — or a camera permission prompt — in the way.
|
||||
assertEquals(emptySet(), capture("application/pdf"))
|
||||
assertEquals(emptySet(), capture(".pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownExtensionOffersNoCamera() {
|
||||
// resolve() widens to */* here, but widening the CAMERA decision would show a camera to a page
|
||||
// that never asked for one.
|
||||
assertEquals(emptySet(), capture(".sqlite3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun caseAndWhitespaceAreNormalized() {
|
||||
val resolved = resolve(" IMAGE/PNG ", ".PNG")
|
||||
|
||||
@@ -1,2 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
Android 11+ package visibility. Without these the sandbox cannot resolve the camera apps, which
|
||||
it must do to name them in grantUriPermission before handing over the capture file. Launching an
|
||||
implicit intent is unaffected; only querying is.
|
||||
-->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.media.action.VIDEO_CAPTURE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application>
|
||||
<!--
|
||||
Hands a camera app the single empty file a WebView capture will be written to. Its own
|
||||
provider with its own authority rather than the app's general-purpose one, so the exposed
|
||||
surface is one cache subdirectory instead of all of cache/ and external files.
|
||||
-->
|
||||
<provider
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletCaptureFileProvider"
|
||||
android:authorities="${applicationId}.napplethost.captures"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/napplet_capture_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
+7
-11
@@ -53,7 +53,6 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.net.toUri
|
||||
@@ -114,10 +113,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
// picker itself; the embedded surfaces have no Activity and route theirs through the main process.
|
||||
private val pendingFileChooser = PendingFileChooser()
|
||||
|
||||
private val fileChooserLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
pendingFileChooser.deliver(NappletFileChooser.parseResult(result.resultCode, result.data))
|
||||
}
|
||||
private val fileChooserLauncher = WebFileChooserLauncher(this) { uris -> pendingFileChooser.deliver(uris) }
|
||||
|
||||
// ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ----
|
||||
private var brokerMessenger: Messenger? = null
|
||||
@@ -435,12 +431,12 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
params: WebChromeClient.FileChooserParams,
|
||||
): Boolean {
|
||||
pendingFileChooser.start(filePathCallback)
|
||||
runCatching { fileChooserLauncher.launch(NappletFileChooser.buildIntent(this, params)) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "No activity available to pick a file", e)
|
||||
pendingFileChooser.cancel()
|
||||
Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
fileChooserLauncher.launch(
|
||||
acceptTypes = params.acceptTypes?.toList().orEmpty(),
|
||||
allowMultiple = NappletFileChooser.allowsMultiple(params.mode),
|
||||
captureEnabled = params.isCaptureEnabled,
|
||||
pageTitle = params.title,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -116,6 +116,13 @@ object NappletBrowserContract {
|
||||
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
|
||||
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
|
||||
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
|
||||
|
||||
/**
|
||||
* The input's `capture` attribute: the page wants a camera, not a stored file. It decides
|
||||
* whether the main process may ask for the CAMERA permission on this request, so it has to
|
||||
* cross with it rather than being re-derived from the accept list.
|
||||
*/
|
||||
const val KEY_FILE_CHOOSER_CAPTURE = "fileChooserCapture"
|
||||
const val KEY_FILE_CHOOSER_TITLE = "fileChooserTitle"
|
||||
const val KEY_FILE_CHOOSER_URIS = "fileChooserUris"
|
||||
|
||||
|
||||
+1
@@ -420,6 +420,7 @@ class NappletBrowserService : Service() {
|
||||
putLong(NappletBrowserContract.KEY_FILE_CHOOSER_ID, id)
|
||||
putStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_ACCEPT, params.acceptTypes ?: emptyArray())
|
||||
putBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_MULTIPLE, NappletFileChooser.allowsMultiple(params.mode))
|
||||
putBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_CAPTURE, params.isCaptureEnabled)
|
||||
putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
/**
|
||||
* Serves the scratch files a camera writes WebView captures into (see [NappletCaptureFiles]).
|
||||
*
|
||||
* Exists as its own class purely so it can be its own `<provider>`: the manifest merger keys provider
|
||||
* nodes by `android:name`, so declaring a second `androidx.core.content.FileProvider` alongside the
|
||||
* app's general-purpose one collides on `authorities`. Subclassing gives this a separate node, a
|
||||
* separate authority, and — the reason it is worth doing — a separate paths file, so the only thing
|
||||
* reachable through it is one cache subdirectory rather than everything the app's provider exposes.
|
||||
*/
|
||||
class NappletCaptureFileProvider : FileProvider()
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The scratch files a camera app writes a WebView capture into, and the [FileProvider] URIs that
|
||||
* expose them.
|
||||
*
|
||||
* `ACTION_IMAGE_CAPTURE` only returns a full-resolution photo when it is handed somewhere to put it
|
||||
* (`EXTRA_OUTPUT`); without one it returns a thumbnail in the result extras, which is useless as an
|
||||
* upload. So each capture option gets its own empty file here first.
|
||||
*
|
||||
* They live in `cacheDir`, which is one directory shared by the main and `:napplet` processes, so it
|
||||
* does not matter which side ran the picker. They cannot be deleted as soon as the capture returns —
|
||||
* the page may not read the file until the user submits the form, which can be minutes later — so
|
||||
* [sweepStale] reclaims them on a later run instead, and the OS can evict the whole directory under
|
||||
* storage pressure regardless.
|
||||
*/
|
||||
object NappletCaptureFiles {
|
||||
private const val DIR = "webview-captures"
|
||||
private const val TAG = "NappletCapture"
|
||||
|
||||
/** Files older than this are assumed read (or abandoned) and are deleted on the next request. */
|
||||
private const val STALE_AFTER_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
/** Authority of the dedicated provider declared in this module's manifest. */
|
||||
private fun authority(context: Context) = "${context.packageName}.napplethost.captures"
|
||||
|
||||
/**
|
||||
* Creates an empty capture file and its content URI, or null when the cache directory cannot be
|
||||
* written — in which case the caller simply omits that camera option rather than offering one that
|
||||
* would come back empty.
|
||||
*/
|
||||
fun create(
|
||||
context: Context,
|
||||
extension: String,
|
||||
): Pair<File, Uri>? =
|
||||
runCatching {
|
||||
val dir = File(context.cacheDir, DIR).apply { mkdirs() }
|
||||
// Not createTempFile's random name: a stable, sortable prefix makes the sweep below able to
|
||||
// recognise our own files and nothing else.
|
||||
val file = File(dir, "capture-${System.currentTimeMillis()}-${counter++}.$extension")
|
||||
file.createNewFile()
|
||||
file to FileProvider.getUriForFile(context, authority(context), file)
|
||||
}.onFailure { Log.w(TAG, "Could not create a capture file", it) }
|
||||
.getOrNull()
|
||||
|
||||
/** Deletes capture files left behind by earlier requests. Called before creating new ones. */
|
||||
fun sweepStale(context: Context) {
|
||||
runCatching {
|
||||
val cutoff = System.currentTimeMillis() - STALE_AFTER_MS
|
||||
File(context.cacheDir, DIR)
|
||||
.listFiles()
|
||||
?.filter { it.isFile && it.lastModified() < cutoff }
|
||||
?.forEach { it.delete() }
|
||||
}.onFailure { Log.w(TAG, "Could not sweep stale capture files", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants every installed camera app write access to [uri].
|
||||
*
|
||||
* A chooser entry supplied through `EXTRA_INITIAL_INTENTS` is started by the system chooser, not by
|
||||
* us, and the URI grant flags on that Intent are not reliably carried across the hop on every
|
||||
* Android version — the camera then cannot open the output file and returns nothing. Granting each
|
||||
* resolved package up front is the part that works everywhere.
|
||||
*
|
||||
* The cost is that the grant necessarily goes to every camera app, not just the one the user is
|
||||
* about to choose (nobody knows that yet), so [releaseGrants] takes it all back the moment the
|
||||
* outcome is known.
|
||||
*/
|
||||
fun grantTo(
|
||||
context: Context,
|
||||
packages: Collection<String>,
|
||||
uri: Uri,
|
||||
) {
|
||||
packages.forEach { pkg ->
|
||||
runCatching { context.grantUriPermission(pkg, uri, GRANT_FLAGS) }
|
||||
.onFailure { Log.w(TAG, "Could not grant capture access to $pkg", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back the grants [grantTo] handed out, so no other app keeps a handle on a photo the user
|
||||
* just took. Called for the kept capture as well as the discarded ones.
|
||||
*
|
||||
* Revokes per package rather than with the whole-URI overload, which is the point: this app reads
|
||||
* the file back through its OWN provider, and same-UID access to a non-exported provider never went
|
||||
* through a grant in the first place. Naming the packages makes it impossible for this to clip our
|
||||
* own read on the way past.
|
||||
*/
|
||||
fun releaseGrants(
|
||||
context: Context,
|
||||
packages: Collection<String>,
|
||||
uri: Uri,
|
||||
) {
|
||||
packages.forEach { pkg ->
|
||||
runCatching { context.revokeUriPermission(pkg, uri, GRANT_FLAGS) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops a capture file the user never filled — a cancelled camera, or the option they didn't take. */
|
||||
fun discard(
|
||||
context: Context,
|
||||
file: File,
|
||||
) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
|
||||
private const val GRANT_FLAGS = Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
|
||||
private var counter = 0
|
||||
}
|
||||
+7
@@ -118,6 +118,13 @@ object NappletEmbedContract {
|
||||
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
|
||||
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
|
||||
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
|
||||
|
||||
/**
|
||||
* The input's `capture` attribute: the page wants a camera, not a stored file. It decides
|
||||
* whether the main process may ask for the CAMERA permission on this request, so it has to
|
||||
* cross with it rather than being re-derived from the accept list.
|
||||
*/
|
||||
const val KEY_FILE_CHOOSER_CAPTURE = "fileChooserCapture"
|
||||
const val KEY_FILE_CHOOSER_TITLE = "fileChooserTitle"
|
||||
const val KEY_FILE_CHOOSER_URIS = "fileChooserUris"
|
||||
|
||||
|
||||
+136
-19
@@ -20,12 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.webkit.WebChromeClient.FileChooserParams
|
||||
import com.vitorpamplona.amethyst.commons.browser.FileChooserAccept
|
||||
import java.io.File
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/**
|
||||
@@ -33,29 +37,56 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
*
|
||||
* A WebView shows no picker of its own: unless the app overrides
|
||||
* `WebChromeClient.onShowFileChooser`, tapping a file input is a silent no-op. Every host here does
|
||||
* override it, and they all build their Intent through this so the browser, the full-screen napplet /
|
||||
* nSite sandbox, and both embedded surfaces filter and multi-select identically.
|
||||
* override it, and they all build their request through this so the browser, the full-screen napplet /
|
||||
* nSite sandbox, and both embedded surfaces filter, multi-select and capture identically.
|
||||
*
|
||||
* The request is described by plain data (accept list, multi-select, title) rather than by a
|
||||
* A request is described by plain data (accept list, multi-select, capture, title) rather than by a
|
||||
* ready-made Intent, because the two embedded hosts have no Activity of their own and must ask the
|
||||
* main process to launch the picker for them. Shipping data keeps the keyless `:napplet` process
|
||||
* unable to hand the trusted process an arbitrary Intent to start — it can only ask for a file picker.
|
||||
* main process to run the picker for them. Shipping data keeps the keyless `:napplet` process unable
|
||||
* to hand the trusted process an arbitrary Intent to start — it can only ask for a file picker.
|
||||
*/
|
||||
object NappletFileChooser {
|
||||
/**
|
||||
* Builds the picker for a request described by [acceptTypes] / [allowMultiple].
|
||||
* A built picker, plus the capture files behind whatever camera options it offers.
|
||||
*
|
||||
* `ACTION_GET_CONTENT` (rather than `ACTION_OPEN_DOCUMENT`) so gallery and camera-roll apps that
|
||||
* are not document providers still show up — the same trade-off Chrome makes; the page only ever
|
||||
* needs to read the bytes once, not to hold a persistable grant. [pageTitle] is the page-supplied
|
||||
* chooser title, used when it set one.
|
||||
* The capture files have to outlive the Intent: a camera writes to `EXTRA_OUTPUT` and returns a
|
||||
* result with no data at all, so the only way to learn what was shot is to look at the files
|
||||
* afterwards ([parseResult]).
|
||||
*/
|
||||
fun buildIntent(
|
||||
class Request internal constructor(
|
||||
val intent: Intent,
|
||||
internal val captures: List<Capture>,
|
||||
)
|
||||
|
||||
/** One camera option's output file, its content URI, and who was granted access to write it. */
|
||||
class Capture internal constructor(
|
||||
internal val file: File,
|
||||
internal val uri: Uri,
|
||||
internal val grantedTo: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the picker for a request described by [acceptTypes] / [allowMultiple] / [captureEnabled].
|
||||
*
|
||||
* `ACTION_GET_CONTENT` (rather than `ACTION_OPEN_DOCUMENT`) so gallery apps that are not document
|
||||
* providers still show up — the same trade-off Chrome makes; the page only needs to read the bytes
|
||||
* once, not hold a persistable grant. When the page accepts photos or video, the matching camera is
|
||||
* offered alongside the file sources, which is what makes an image-accepting input behave the way it
|
||||
* does in a mobile browser instead of only reaching already-saved files.
|
||||
*
|
||||
* [cameraAllowed] must be the caller's live CAMERA-permission state: `ACTION_IMAGE_CAPTURE` throws
|
||||
* `SecurityException` for an app that declares the CAMERA permission without holding it, and
|
||||
* Amethyst declares it. Callers request it first when the page asked for a camera outright
|
||||
* ([captureEnabled]); see [wantsCamera].
|
||||
*/
|
||||
fun buildRequest(
|
||||
context: Context,
|
||||
acceptTypes: List<String>,
|
||||
allowMultiple: Boolean,
|
||||
captureEnabled: Boolean,
|
||||
cameraAllowed: Boolean,
|
||||
pageTitle: CharSequence? = null,
|
||||
): Intent {
|
||||
): Request {
|
||||
val mimeMap = MimeTypeMap.getSingleton()
|
||||
val resolved = FileChooserAccept.resolve(acceptTypes) { ext -> mimeMap.getMimeTypeFromExtension(ext) }
|
||||
|
||||
@@ -74,22 +105,45 @@ object NappletFileChooser {
|
||||
}
|
||||
if (allowMultiple) pick.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
|
||||
val captures = if (cameraAllowed) buildCaptureOptions(context, acceptTypes) else emptyList()
|
||||
|
||||
val title = pageTitle?.takeIf { it.isNotBlank() } ?: context.getString(CommonsR.string.browser_file_chooser_title)
|
||||
return Intent.createChooser(pick, title)
|
||||
val chooser = Intent.createChooser(pick, title)
|
||||
|
||||
if (captures.isNotEmpty()) {
|
||||
// At most two entries (stills + video), which is also all the system chooser will display
|
||||
// from EXTRA_INITIAL_INTENTS — anything beyond that would be silently dropped.
|
||||
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, captures.map { it.first }.toTypedArray())
|
||||
}
|
||||
|
||||
return Request(chooser, captures.map { it.second })
|
||||
}
|
||||
|
||||
/** Convenience overload for the two Activity hosts, which hold the real [FileChooserParams]. */
|
||||
fun buildIntent(
|
||||
fun buildRequest(
|
||||
context: Context,
|
||||
params: FileChooserParams,
|
||||
): Intent =
|
||||
buildIntent(
|
||||
cameraAllowed: Boolean,
|
||||
): Request =
|
||||
buildRequest(
|
||||
context = context,
|
||||
acceptTypes = params.acceptTypes?.toList().orEmpty(),
|
||||
allowMultiple = allowsMultiple(params.mode),
|
||||
captureEnabled = params.isCaptureEnabled,
|
||||
cameraAllowed = cameraAllowed,
|
||||
pageTitle = params.title,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a picker for [acceptTypes] would offer a camera at all — i.e. whether it is worth holding
|
||||
* (or asking for) the CAMERA permission. False for a page that only accepts documents, so opening a
|
||||
* PDF upload never triggers a camera prompt.
|
||||
*/
|
||||
fun wantsCamera(acceptTypes: List<String>): Boolean {
|
||||
val mimeMap = MimeTypeMap.getSingleton()
|
||||
return FileChooserAccept.captureMedia(acceptTypes) { ext -> mimeMap.getMimeTypeFromExtension(ext) }.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [mode] should let the user pick more than one file.
|
||||
*
|
||||
@@ -103,11 +157,74 @@ object NappletFileChooser {
|
||||
fun allowsMultiple(mode: Int): Boolean = mode == FileChooserParams.MODE_OPEN_MULTIPLE || mode == FileChooserParams.MODE_OPEN_FOLDER
|
||||
|
||||
/**
|
||||
* Turns an activity result into the array the page's `filePathCallback` expects, or null when the
|
||||
* user backed out. Handles both the single-URI and the `ClipData` multi-select shapes.
|
||||
* Turns an activity result into the array the page's `filePathCallback` expects, or null when
|
||||
* nothing was chosen. Handles the single-URI and `ClipData` multi-select shapes, and the camera
|
||||
* shape — where the result carries no URI at all and the evidence of a capture is a scratch file
|
||||
* that now has bytes in it. Every capture file this request created and did not return is deleted
|
||||
* here, so a cancelled or unused camera option leaves nothing behind.
|
||||
*/
|
||||
fun parseResult(
|
||||
context: Context,
|
||||
request: Request,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
): Array<Uri>? = FileChooserParams.parseResult(resultCode, data)
|
||||
): Array<Uri>? {
|
||||
val picked = FileChooserParams.parseResult(resultCode, data)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// A camera returns no URI at all — it reports success by filling the file we handed it, so an
|
||||
// empty one means it was dismissed. Only consulted when the picker returned nothing of its own.
|
||||
val captured =
|
||||
if (picked != null || resultCode != Activity.RESULT_OK) {
|
||||
null
|
||||
} else {
|
||||
request.captures.firstOrNull { it.file.length() > 0 }
|
||||
}
|
||||
|
||||
request.captures.forEach { capture ->
|
||||
// Every camera app was granted write access up front because none of them could be ruled
|
||||
// out yet. The outcome is known now, so none of them needs it any more — including for the
|
||||
// photo being returned, which this app reads back through its own provider.
|
||||
NappletCaptureFiles.releaseGrants(context, capture.grantedTo, capture.uri)
|
||||
if (capture !== captured) NappletCaptureFiles.discard(context, capture.file)
|
||||
}
|
||||
|
||||
return picked ?: captured?.let { arrayOf(it.uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera option per medium the page accepts, each with its own output file. Media with no
|
||||
* installed handler are skipped so the chooser never shows an entry that dead-ends.
|
||||
*/
|
||||
private fun buildCaptureOptions(
|
||||
context: Context,
|
||||
acceptTypes: List<String>,
|
||||
): List<Pair<Intent, Capture>> {
|
||||
val mimeMap = MimeTypeMap.getSingleton()
|
||||
val media = FileChooserAccept.captureMedia(acceptTypes) { ext -> mimeMap.getMimeTypeFromExtension(ext) }
|
||||
if (media.isEmpty()) return emptyList()
|
||||
|
||||
NappletCaptureFiles.sweepStale(context)
|
||||
|
||||
return media.mapNotNull { medium ->
|
||||
val (action, extension) =
|
||||
when (medium) {
|
||||
FileChooserAccept.CaptureMedia.IMAGE -> MediaStore.ACTION_IMAGE_CAPTURE to "jpg"
|
||||
FileChooserAccept.CaptureMedia.VIDEO -> MediaStore.ACTION_VIDEO_CAPTURE to "mp4"
|
||||
}
|
||||
|
||||
val handlers = context.packageManager.queryIntentActivities(Intent(action), PackageManager.MATCH_DEFAULT_ONLY)
|
||||
if (handlers.isEmpty()) return@mapNotNull null
|
||||
|
||||
val (file, uri) = NappletCaptureFiles.create(context, extension) ?: return@mapNotNull null
|
||||
val packages = handlers.map { it.activityInfo.packageName }.distinct()
|
||||
NappletCaptureFiles.grantTo(context, packages, uri)
|
||||
|
||||
val intent =
|
||||
Intent(action)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
intent to Capture(file, uri, packages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-11
@@ -56,7 +56,6 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.ProxyConfig
|
||||
@@ -108,10 +107,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// Registered as a field so it exists before onCreate returns (registerForActivityResult's contract).
|
||||
private val pendingFileChooser = PendingFileChooser()
|
||||
|
||||
private val fileChooserLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
pendingFileChooser.deliver(NappletFileChooser.parseResult(result.resultCode, result.data))
|
||||
}
|
||||
private val fileChooserLauncher = WebFileChooserLauncher(this) { uris -> pendingFileChooser.deliver(uris) }
|
||||
|
||||
private val paths = mutableListOf<PathTag>()
|
||||
private val servers = mutableListOf<String>()
|
||||
@@ -703,12 +699,12 @@ class NappletHostActivity : ComponentActivity() {
|
||||
params: WebChromeClient.FileChooserParams,
|
||||
): Boolean {
|
||||
pendingFileChooser.start(filePathCallback)
|
||||
runCatching { fileChooserLauncher.launch(NappletFileChooser.buildIntent(this, params)) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "No activity available to pick a file", e)
|
||||
pendingFileChooser.cancel()
|
||||
Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
fileChooserLauncher.launch(
|
||||
acceptTypes = params.acceptTypes?.toList().orEmpty(),
|
||||
allowMultiple = NappletFileChooser.allowsMultiple(params.mode),
|
||||
captureEnabled = params.isCaptureEnabled,
|
||||
pageTitle = params.title,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -463,6 +463,7 @@ class NappletHostService : Service() {
|
||||
putLong(NappletEmbedContract.KEY_FILE_CHOOSER_ID, id)
|
||||
putStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_ACCEPT, params.acceptTypes ?: emptyArray())
|
||||
putBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_MULTIPLE, NappletFileChooser.allowsMultiple(params.mode))
|
||||
putBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_CAPTURE, params.isCaptureEnabled)
|
||||
putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.commons.R as CommonsR
|
||||
|
||||
/**
|
||||
* Runs one HTML file-input pick from [activity], camera options included, and reports the result to
|
||||
* [onResult] — exactly once per [launch], with null meaning "nothing chosen".
|
||||
*
|
||||
* Every surface that can start an Activity uses this, so the full-screen browser, the full-screen
|
||||
* napplet / nSite sandbox, and the main-process host that serves the two embedded surfaces cannot
|
||||
* drift apart in how they filter, multi-select, or offer the camera.
|
||||
*
|
||||
* Must be constructed as a **field** of [activity]: it registers its activity-result contracts in the
|
||||
* constructor, and `registerForActivityResult` has to run before the activity reaches STARTED.
|
||||
*/
|
||||
class WebFileChooserLauncher(
|
||||
private val activity: ComponentActivity,
|
||||
private val onResult: (Array<Uri>?) -> Unit,
|
||||
) {
|
||||
/** What [launch] was asked for, held across a permission round-trip. */
|
||||
private class Ask(
|
||||
val acceptTypes: List<String>,
|
||||
val allowMultiple: Boolean,
|
||||
val captureEnabled: Boolean,
|
||||
val pageTitle: CharSequence?,
|
||||
)
|
||||
|
||||
private var ask: Ask? = null
|
||||
private var request: NappletFileChooser.Request? = null
|
||||
|
||||
private val chooser =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val pending = request
|
||||
request = null
|
||||
val uris = pending?.let { NappletFileChooser.parseResult(activity, it, result.resultCode, result.data) }
|
||||
onResult(uris)
|
||||
}
|
||||
|
||||
private val cameraPermission =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
// Denied is not a failure: the page still gets the file sources, it just cannot shoot a new
|
||||
// photo. Falling back beats reporting nothing and leaving the input dead.
|
||||
open(cameraAllowed = granted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the picker for a page's file input.
|
||||
*
|
||||
* When the page asked for a camera outright (`capture`) and CAMERA is not held yet, the permission
|
||||
* is requested first — the user tapped a control whose whole purpose is to take a photo, so the
|
||||
* prompt is expected. Without `capture` the camera is offered only if the permission is already
|
||||
* held, so choosing a document never raises a camera prompt out of nowhere.
|
||||
*/
|
||||
fun launch(
|
||||
acceptTypes: List<String>,
|
||||
allowMultiple: Boolean,
|
||||
captureEnabled: Boolean,
|
||||
pageTitle: CharSequence?,
|
||||
) {
|
||||
ask = Ask(acceptTypes, allowMultiple, captureEnabled, pageTitle)
|
||||
|
||||
if (captureEnabled && !hasCameraPermission() && NappletFileChooser.wantsCamera(acceptTypes)) {
|
||||
val requested = runCatching { cameraPermission.launch(Manifest.permission.CAMERA) }.isSuccess
|
||||
if (requested) return
|
||||
}
|
||||
open(cameraAllowed = hasCameraPermission())
|
||||
}
|
||||
|
||||
private fun open(cameraAllowed: Boolean) {
|
||||
val pending = ask ?: return onResult(null)
|
||||
ask = null
|
||||
|
||||
val built =
|
||||
NappletFileChooser.buildRequest(
|
||||
context = activity,
|
||||
acceptTypes = pending.acceptTypes,
|
||||
allowMultiple = pending.allowMultiple,
|
||||
captureEnabled = pending.captureEnabled,
|
||||
cameraAllowed = cameraAllowed,
|
||||
pageTitle = pending.pageTitle,
|
||||
)
|
||||
request = built
|
||||
|
||||
runCatching { chooser.launch(built.intent) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "No activity available to pick a file", e)
|
||||
request = null
|
||||
// Runs the cancel path so the capture scratch files this request created are cleaned up.
|
||||
NappletFileChooser.parseResult(activity, built, Activity.RESULT_CANCELED, null)
|
||||
Toast.makeText(activity, activity.getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
|
||||
onResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCameraPermission() = ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "WebFileChooser"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Scoped to the one cache subdirectory the camera writes WebView captures into. Deliberately NOT the
|
||||
app-wide file_paths.xml (which exposes all of cache/ and external files) — the only thing a camera
|
||||
app should ever be handed is the empty file it is about to fill.
|
||||
-->
|
||||
<paths>
|
||||
<cache-path
|
||||
name="webview_captures"
|
||||
path="webview-captures/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user