diff --git a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
index 2d52c383..a44ff124 100644
--- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
+++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt
@@ -1,6 +1,7 @@
package net.activitywatch.android.fragments
import android.annotation.SuppressLint
+import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -13,6 +14,8 @@ import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.URLUtil
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -230,6 +233,39 @@ internal fun sanitizeExportFilename(filename: String): String {
return name.takeIf { it.isNotEmpty() } ?: "export"
}
+/**
+ * WebView's default (no WebChromeClient) file picker is camera/gallery.
+ * Category import uses `` with no `accept`, so an untyped
+ * input must open a generic document picker (aw-android#247).
+ */
+internal fun resolvedFileChooserMimeTypes(acceptTypes: Array?): List {
+ val types = acceptTypes.orEmpty().filter { it.isNotBlank() && it != "*/*" }
+ return types.ifEmpty { listOf("*/*") }
+}
+
+internal fun fileChooserIntentForAcceptTypes(acceptTypes: Array?): Intent {
+ val types = resolvedFileChooserMimeTypes(acceptTypes)
+ return Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ type = types.first()
+ if (types.size > 1) {
+ putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
+ }
+ }
+}
+
+internal fun parseFileChooserResult(resultCode: Int, data: Intent?): Array? {
+ if (resultCode != Activity.RESULT_OK || data == null) {
+ return null
+ }
+ val clip = data.clipData
+ if (clip != null && clip.itemCount > 0) {
+ return Array(clip.itemCount) { index -> clip.getItemAt(index).uri }
+ }
+ return data.data?.let { arrayOf(it) }
+}
+
internal fun inferExportMimeType(filename: String, explicit: String? = null): String {
val given = explicit?.trim().orEmpty()
if (given.isNotEmpty() && given != "application/octet-stream") {
@@ -261,6 +297,7 @@ class WebUIFragment : Fragment() {
private var listener: OnFragmentInteractionListener? = null
private var webView: WebView? = null
private val exportQueue = ExportSaveQueue()
+ private var filePathCallback: ValueCallback>? = null
private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("*/*")
@@ -268,6 +305,14 @@ class WebUIFragment : Fragment() {
onExportDocumentCreated(uri)
}
+ private val fileChooserLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ val callback = filePathCallback
+ filePathCallback = null
+ callback?.onReceiveValue(parseFileChooserResult(result.resultCode, result.data))
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
savedInstanceState?.let { state ->
@@ -345,6 +390,26 @@ class WebUIFragment : Fragment() {
}
}
myWebView.webViewClient = MyWebViewClient()
+ myWebView.webChromeClient = object : WebChromeClient() {
+ override fun onShowFileChooser(
+ webView: WebView?,
+ filePathCallback: ValueCallback>?,
+ fileChooserParams: FileChooserParams?
+ ): Boolean {
+ this@WebUIFragment.filePathCallback?.onReceiveValue(null)
+ this@WebUIFragment.filePathCallback = filePathCallback
+ val intent = fileChooserIntentForAcceptTypes(fileChooserParams?.acceptTypes)
+ return try {
+ fileChooserLauncher.launch(intent)
+ true
+ } catch (e: ActivityNotFoundException) {
+ Log.e(TAG, "No activity to handle file chooser", e)
+ this@WebUIFragment.filePathCallback?.onReceiveValue(null)
+ this@WebUIFragment.filePathCallback = null
+ false
+ }
+ }
+ }
myWebView.setDownloadListener { url, _, contentDisposition, mimeType, _ ->
handleWebViewDownload(url, contentDisposition, mimeType)
@@ -361,6 +426,8 @@ class WebUIFragment : Fragment() {
}
override fun onDestroyView() {
+ filePathCallback?.onReceiveValue(null)
+ filePathCallback = null
webView = null
super.onDestroyView()
}
diff --git a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt
index 1bf9c408..0b1b0437 100644
--- a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt
+++ b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt
@@ -8,6 +8,25 @@ import org.junit.Test
import java.io.File
class WebUIFragmentTest {
+ @Test
+ fun `untyped file input uses a generic mime type`() {
+ assertEquals(listOf("*/*"), resolvedFileChooserMimeTypes(null))
+ assertEquals(listOf("*/*"), resolvedFileChooserMimeTypes(arrayOf()))
+ assertEquals(listOf("*/*"), resolvedFileChooserMimeTypes(arrayOf("", "*/*")))
+ }
+
+ @Test
+ fun `explicit file input accept types are preserved`() {
+ assertEquals(
+ listOf("application/json"),
+ resolvedFileChooserMimeTypes(arrayOf("application/json")),
+ )
+ assertEquals(
+ listOf("text/csv", "application/json"),
+ resolvedFileChooserMimeTypes(arrayOf("text/csv", "application/json")),
+ )
+ }
+
@Test
fun `treats local embedded server hosts as internal`() {
// http variants