Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A napplet's file field opens the file picker.** Tapping an
`<input type="file">` in a napplet used to do nothing. It now opens
Android's file picker, and the picker shows every kind of file the field
accepts: `accept=".csv,text/csv"` offers CSV files however Android labels
them, and a type Android doesn't know opens the picker to all files.

- **A napplet's mesh subscription gets everything nearby phones send back.**
When `mesh.subscribe` asks Circle peers for their backlog, an event this
phone had seen once and since let go of (evicted from the cache, or an
Expand Down
92 changes: 92 additions & 0 deletions android/app/src/main/java/app/myco/NappletActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.ViewGroup
import android.content.ActivityNotFoundException
import android.webkit.RenderProcessGoneDetail
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
Expand All @@ -23,6 +26,7 @@ import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
Expand Down Expand Up @@ -148,6 +152,19 @@ class NappletActivity : ComponentActivity() {
*/
private var sessionId: String = ""

/**
* The page waiting on a file picker, if any. WebView asks the app to show
* one for an `<input type="file">`; without [WebChromeClient.onShowFileChooser]
* the button does nothing. What the user picks goes to this window only,
* the way a browser hands a picked file to the page that asked.
*/
private var pendingFiles: ValueCallback<Array<Uri>>? = null

private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
pendingFiles?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data))
pendingFiles = null
}

/** Set by [onDestroy]; a session opened after this is closed by its opener. */
private var windowGone = false
private val sessionLock = Any()
Expand Down Expand Up @@ -732,6 +749,26 @@ class NappletActivity : ComponentActivity() {
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.mediaPlaybackRequiresUserGesture = false
webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
view: WebView,
callback: ValueCallback<Array<Uri>>,
params: FileChooserParams,
): Boolean {
// One picker at a time: a page asking again cancels the first.
pendingFiles?.onReceiveValue(null)
pendingFiles = callback
return try {
pickFiles.launch(fileChooserIntent(params.acceptTypes, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE))
true
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "no app can pick files", e)
pendingFiles = null
callback.onReceiveValue(null)
true
}
}
}
webViewClient = NappletWebViewClient(
client = client,
shellHostOf = { shellHost },
Expand Down Expand Up @@ -1379,3 +1416,58 @@ private class NappletWebViewClient(
const val BLOB_PREFIX = "/_blob/"
}
}

/**
* The picker intent for an `<input type="file" accept="…">`.
*
* Not [WebChromeClient.FileChooserParams.createIntent]: it takes only the
* first accept entry and passes it on as a MIME type, so `accept=".csv,text/csv"`
* asked the picker for files of type ".csv" — none — and nothing could be
* chosen. Here every entry counts: extensions become MIME types, CSV gets the
* names Android also files it under, and anything unrecognised opens the
* picker to all files rather than to none.
*/
internal fun fileChooserIntent(accept: Array<String>?, multiple: Boolean): Intent {
val mimes = acceptedMimeTypes(accept)
return Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimes.singleOrNull() ?: "*/*"
if (mimes.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, mimes.toTypedArray())
if (multiple) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
}

/** MIME types for an `accept` list; empty means "any file". */
internal fun acceptedMimeTypes(accept: Array<String>?): List<String> {
val out = linkedSetOf<String>()
for (raw in accept.orEmpty().flatMap { it.split(',') }) {
val a = raw.trim().lowercase()
when {
a.isEmpty() -> Unit
a.startsWith(".") -> {
val ext = a.removePrefix(".")
EXTRA_MIMES[ext]?.let(out::addAll)
?: android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.let(out::add)
// An extension nobody knows: let the user find it among all files.
?: return emptyList()
}
a.contains('/') -> {
out.add(a)
MIME_ALIASES[a]?.let(out::addAll)
}
else -> return emptyList()
}
}
return out.toList()
}

/** Extensions whose files Android labels in more than one way. */
private val EXTRA_MIMES = mapOf(
"csv" to listOf("text/csv", "text/comma-separated-values", "application/csv", "text/plain"),
"json" to listOf("application/json", "text/plain"),
"glb" to listOf("model/gltf-binary", "application/octet-stream"),
)

private val MIME_ALIASES = mapOf(
"text/csv" to listOf("text/comma-separated-values", "application/csv"),
)
28 changes: 28 additions & 0 deletions android/app/src/test/java/app/myco/FileChooserTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package app.myco

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/** `accept` → MIME types for the napplet file picker. */
class FileChooserTest {
@Test
fun csvAcceptsEveryNameAndroidGivesIt() {
val mimes = acceptedMimeTypes(arrayOf(".csv,text/csv"))
assertTrue("text/csv" in mimes)
assertTrue("text/comma-separated-values" in mimes)
assertEquals(mimes.size, mimes.toSet().size)
}

@Test
fun nothingOrSomethingUnknownMeansAnyFile() {
assertEquals(emptyList<String>(), acceptedMimeTypes(null))
assertEquals(emptyList<String>(), acceptedMimeTypes(arrayOf("")))
assertEquals(emptyList<String>(), acceptedMimeTypes(arrayOf("csv")))
}

@Test
fun aPlainMimeTypePassesThrough() {
assertEquals(listOf("image/png"), acceptedMimeTypes(arrayOf("image/png")))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ class SystemAskTest {
Regex("""requestPermissions\(""") to setOf(REQUESTER),
Regex("""shouldShowRequestPermissionRationale""") to setOf(REQUESTER),
// Launchers that ask Android nothing: the Amber signer and file pickers.
Regex("""registerForActivityResult""") to setOf(REQUESTER, "signer/SignerActivity.kt"),
Regex("""registerForActivityResult""") to setOf(
REQUESTER,
"signer/SignerActivity.kt", // Amber signer
"NappletActivity.kt", // file picker: a napplet's <input type="file">
),
Regex("""rememberLauncherForActivityResult""") to setOf(
"ui/MycoApp.kt", // file picker: send files to a peer
"ui/screens/CircleScreen.kt", // file picker: hotspot share
Expand Down
Loading