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..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,11 +1,14 @@
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
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 +21,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 +121,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 +170,45 @@ 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) {
+ // 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,
+ "${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)
+ try {
+ mContext.startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e(TAG, "No viewer app found for $mimetype", e)
+ }
+ }
+}
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 @@
+
+
+
+