From bf11e388636c916baa6fdee8611654efade68821 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 12 Jul 2026 17:35:00 +0000 Subject: [PATCH 1/2] fix: CSV/JSON export via WebAppInterface with FileProvider Adds a JavaScript bridge so the webui can trigger native file downloads on Android. The original download-as-blob approach crashed because the Android WebView cannot initiate system downloads the same way a browser does. Changes: - WebAppInterface: exposes downloadCSV() and downloadJSON() to window.Android - Uses FileProvider (required API 24+) instead of Uri.fromFile(), which throws FileUriExposedException on Android 7.0 and later - Adds res/xml/file_paths.xml for the provider's path config - Declares the FileProvider in AndroidManifest.xml Depends on ActivityWatch/aw-webui#532 (webui side of the bridge). Fixes #104 --- mobile/src/main/AndroidManifest.xml | 10 ++++++ .../android/fragments/WebUIFragment.kt | 31 +++++++++++++++++++ mobile/src/main/res/xml/file_paths.xml | 4 +++ 3 files changed, 45 insertions(+) create mode 100644 mobile/src/main/res/xml/file_paths.xml diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 5c377395..1b8d3e6a 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -113,5 +113,15 @@ android:name="android.appwidget.provider" android:resource="@xml/category_time_widget_info" /> + + + + \ No newline at end of file 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 ba2fdc43..60c9a43e 100644 --- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt +++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Bundle +import android.webkit.JavascriptInterface +import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View @@ -18,6 +20,7 @@ import android.webkit.URLUtil import android.webkit.WebResourceRequest import android.webkit.WebViewClient import net.activitywatch.android.R +import java.io.File import java.lang.Thread.sleep import java.net.URI @@ -117,6 +120,7 @@ class WebUIFragment : Fragment() { myWebView.settings.javaScriptEnabled = true myWebView.settings.domStorageEnabled = true + myWebView.addJavascriptInterface(WebAppInterface(requireContext()), "Android") arguments?.let { it.getString(ARG_URL)?.let { it1 -> myWebView.loadUrl(it1) } } @@ -165,3 +169,30 @@ class WebUIFragment : Fragment() { } } } + +class WebAppInterface(private val mContext: Context) { + @JavascriptInterface + fun downloadCSV(csv: String, filename: String) { + downloadFile(csv, filename, "text/csv") + } + + @JavascriptInterface + fun downloadJSON(json: String, filename: String) { + downloadFile(json, filename, "application/json") + } + + private fun downloadFile(content: String, filename: String, mimetype: String) { + val file = File(mContext.getExternalFilesDir(null), filename) + file.writeText(content) + // FileProvider required on API 24+: Uri.fromFile() throws FileUriExposedException + val uri = FileProvider.getUriForFile( + mContext, + "${mContext.packageName}.provider", + file + ) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, mimetype) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NO_HISTORY) + mContext.startActivity(intent) + } +} diff --git a/mobile/src/main/res/xml/file_paths.xml b/mobile/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..6377fe7e --- /dev/null +++ b/mobile/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + From 47bea06d29eba34d1b01b6fd023cbfbcdea7669f Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 12 Jul 2026 17:45:46 +0000 Subject: [PATCH 2/2] fix: harden downloadFile against path traversal, null extDir, write errors, missing viewer --- .../android/fragments/WebUIFragment.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 60c9a43e..87604e1c 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.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo @@ -182,8 +183,19 @@ class WebAppInterface(private val mContext: Context) { } private fun downloadFile(content: String, filename: String, mimetype: String) { - val file = File(mContext.getExternalFilesDir(null), filename) - file.writeText(content) + // Strip path components from the JS-supplied name to prevent export-root escape + val safeName = File(filename).name.takeIf { it.isNotEmpty() } ?: "export" + val externalDir = mContext.getExternalFilesDir(null) ?: run { + Log.e(TAG, "External files directory unavailable") + return + } + val file = File(externalDir, safeName) + try { + file.writeText(content) + } catch (e: Exception) { + Log.e(TAG, "Failed to write export file: ${e.message}") + return + } // FileProvider required on API 24+: Uri.fromFile() throws FileUriExposedException val uri = FileProvider.getUriForFile( mContext, @@ -193,6 +205,10 @@ class WebAppInterface(private val mContext: Context) { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(uri, mimetype) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NO_HISTORY) - mContext.startActivity(intent) + try { + mContext.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No viewer app found for $mimetype", e) + } } }