diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 85a6fe3278..cc97cdc282 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -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" /> ` 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 `` 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" - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt index e715f8fc20..3891676d3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt @@ -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 * `` 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, + val allowMultiple: Boolean, + val captureEnabled: Boolean, + val pageTitle: String?, val onResult: (Array?) -> Unit, ) private val pending = ConcurrentHashMap() /** - * 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, allowMultiple: Boolean, + captureEnabled: Boolean, pageTitle: String?, onResult: (Array?) -> 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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index b50a6872c4..3858281234 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 39661a0bae..39d3fb0f6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -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) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt index 3e8cffd70a..a96fe90a9c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt @@ -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 `` 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, + extensionToMime: (String) -> String?, + ): Set { + 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() + 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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt index aaa4b8e202..613a256524 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt @@ -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") diff --git a/nappletHost/src/main/AndroidManifest.xml b/nappletHost/src/main/AndroidManifest.xml index b2d3ea1235..e01198c647 100644 --- a/nappletHost/src/main/AndroidManifest.xml +++ b/nappletHost/src/main/AndroidManifest.xml @@ -1,2 +1,35 @@ - + + + + + + + + + + + + + + + + + + + + diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 16b2bcd593..9cc171e9e4 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -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 } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index ae6d908dcc..4725eb7074 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index bea0f025e2..d82d512c88 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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()) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFileProvider.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFileProvider.kt new file mode 100644 index 0000000000..e9a0655a78 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFileProvider.kt @@ -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 ``: 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() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFiles.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFiles.kt new file mode 100644 index 0000000000..5f55d0eaad --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletCaptureFiles.kt @@ -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? = + 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, + 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, + 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 +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 02b8892acc..1f977a02d3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt index 48c64b19b1..b212872ced 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt @@ -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, + ) + + /** 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, + ) + + /** + * 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, 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): 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? = FileChooserParams.parseResult(resultCode, data) + ): Array? { + 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, + ): List> { + 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) + } + } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d1c2aab8d4..7abf81805b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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() private val servers = mutableListOf() @@ -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 } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 24ebeec70d..e3456279ea 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -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()) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebFileChooserLauncher.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebFileChooserLauncher.kt new file mode 100644 index 0000000000..9cd4ecac21 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebFileChooserLauncher.kt @@ -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?) -> Unit, +) { + /** What [launch] was asked for, held across a permission round-trip. */ + private class Ask( + val acceptTypes: List, + 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, + 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" + } +} diff --git a/nappletHost/src/main/res/xml/napplet_capture_paths.xml b/nappletHost/src/main/res/xml/napplet_capture_paths.xml new file mode 100644 index 0000000000..ee39b8d8c3 --- /dev/null +++ b/nappletHost/src/main/res/xml/napplet_capture_paths.xml @@ -0,0 +1,11 @@ + + + + +