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
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ internal val ANDROID_EXPORT_HOOK_JS = """
event.stopPropagation();
window.__awAndroidSendBlob(href, el.getAttribute('download') || 'export');
}, true);

// JSON bucket exports go through axios today. Abort that XHR and stream
// natively so a 30s timeout / JSON.parse of 500k events cannot kill the save.
Comment thread
TimeToBuildBob marked this conversation as resolved.
var xhr = XMLHttpRequest.prototype;
var origOpen = xhr.open;
var origSend = xhr.send;
xhr.open = function (method, url) {
this.__awExportUrl = typeof url === 'string' ? url : '';
return origOpen.apply(this, arguments);
};
xhr.send = function () {
var url = this.__awExportUrl || '';
var match = url.match(/\/0\/(?:buckets\/([^/?#]+)\/)?export(?:[?#]|$)/);
if (match && typeof Android !== 'undefined' && Android.exportFromUrl) {
var filename = match[1]
? ('aw-bucket-export-' + decodeURIComponent(match[1]) + '.json')
: 'aw-bucket-export.json';
Android.exportFromUrl(url, filename);
this.abort();
return;
}
return origSend.apply(this, arguments);
};
})();
""".trimIndent()

Expand Down Expand Up @@ -253,9 +276,36 @@ internal fun readExportSnapshot(state: Bundle): ExportQueueSnapshot? {
}

internal fun persistExportPayload(cacheDir: File, content: String): File {
return persistExportStream(cacheDir, content.byteInputStream(StandardCharsets.UTF_8))
}

internal fun persistExportStream(cacheDir: File, input: java.io.InputStream): File {
val dir = File(cacheDir, "exports").apply { mkdirs() }
return File(dir, "${java.util.UUID.randomUUID()}.export").apply {
writeText(content, StandardCharsets.UTF_8)
val file = File(dir, "${java.util.UUID.randomUUID()}.export")
try {
file.outputStream().use { output -> input.copyTo(output) }
} catch (e: Exception) {
if (file.exists() && !file.delete()) {
Log.w(TAG, "Failed to delete incomplete export cache ${file.name}")
}
throw e
}
return file
}

internal fun resolveEmbeddedExportUrl(url: String, baseUrl: String = "http://127.0.0.1:5600/"): String? {
val trimmed = url.trim()
if (trimmed.isEmpty()) {
return null
}
return try {
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
URI(baseUrl).resolve(trimmed).toString()
}
} catch (_: Exception) {
null
}
}

Expand Down Expand Up @@ -484,7 +534,7 @@ class WebUIFragment : Fragment() {
myWebView.settings.javaScriptEnabled = true
myWebView.settings.domStorageEnabled = true
myWebView.addJavascriptInterface(
WebAppInterface(::queueExport, ::onColorSchemeReported),
WebAppInterface(::queueExport, ::onColorSchemeReported, ::onExportFromUrl),
"Android",
)
arguments?.let {
Expand All @@ -499,6 +549,22 @@ class WebUIFragment : Fragment() {
reloadHandler.postDelayed(reloadRunnable, delay)
}

/**
* Always run [action] on the main looper.
*
* JS-bridge callbacks and background fetch completions must not use
* `view?.post`: a detached view drops the runnable, so a finished export
* is never queued and the cache file leaks. The fragment Handler still
* fires after the view is gone; callers then enqueue or delete.
*/
private fun postToUi(action: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
action()
} else {
reloadHandler.post(action)
}
}

override fun onDestroyView() {
reloadHandler.removeCallbacks(reloadRunnable)
filePathCallback?.onReceiveValue(null)
Expand Down Expand Up @@ -531,13 +597,34 @@ class WebUIFragment : Fragment() {
}
}

private fun onExportFromUrl(url: String, filename: String) {
// @JavascriptInterface runs on the WebView bridge thread. webView.url
// (and Toast) are UI-thread only.
postToUi {
if (!isAdded) return@postToUi
val base = webView?.url ?: "http://127.0.0.1:5600/"
val resolved = resolveEmbeddedExportUrl(url, base)
if (resolved == null || !isEmbeddedActivityWatchUrl(resolved)) {
Log.w(TAG, "Rejected export URL: $url")
showExportToast(getString(R.string.export_save_failed), long = true)
return@postToUi
}
downloadEmbeddedExport(resolved, filename, inferExportMimeType(filename, null))
}
}

private fun downloadEmbeddedExport(url: String, filename: String, mimeType: String?) {
val token = context?.let { ensureDashboardApiKey(it) }.orEmpty()
val cacheDir = context?.applicationContext?.cacheDir ?: return
val safeName = sanitizeExportFilename(filename)
val resolvedMime = inferExportMimeType(safeName, mimeType)
thread(name = "aw-export-fetch") {
val result = runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
// Streamed exports send headers immediately, then trickle
// events. Allow long gaps without holding the JSON in RAM.
readTimeout = 120_000
instanceFollowRedirects = true
if (token.isNotEmpty()) {
setRequestProperty("Authorization", "Bearer $token")
Expand All @@ -548,22 +635,20 @@ class WebUIFragment : Fragment() {
if (code !in 200..299) {
error("export HTTP $code")
}
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
persistExportStream(cacheDir, connection.inputStream)
} finally {
connection.disconnect()
}
}
view?.post {
result.fold(
onSuccess = { body ->
queueExport(body, filename, inferExportMimeType(filename, mimeType))
},
onFailure = { error ->
Log.e(TAG, "Failed to fetch export from $url", error)
showExportToast(getString(R.string.export_save_failed), long = true)
},
)
}
result.fold(
onSuccess = { file ->
queueExportFile(file, safeName, resolvedMime)
},
onFailure = { error ->
Log.e(TAG, "Failed to fetch export from $url", error)
notifyExportFailed()
},
)
}
}

Expand All @@ -576,28 +661,34 @@ class WebUIFragment : Fragment() {
PendingExport(safeName, resolvedMime, persistExportPayload(cacheDir, content))
} catch (e: Exception) {
Log.e(TAG, "Failed to persist export payload", e)
val notify = {
notifyExportFailed()
return
}
enqueuePending(pending)
}

private fun queueExportFile(file: File, filename: String, mimeType: String) {
Log.i(TAG, "Export save requested: $filename ($mimeType, ${file.length()} bytes)")
enqueuePending(PendingExport(filename, mimeType, file))
}

private fun notifyExportFailed() {
postToUi {
if (isAdded) {
showExportToast(getString(R.string.export_save_failed), long = true)
}
view?.post(notify) ?: if (isAdded) requireActivity().runOnUiThread(notify) else Unit
return
}
val enqueue = {
}

private fun enqueuePending(pending: PendingExport) {
postToUi {
if (isAdded) {
exportQueue.enqueue(pending)
launchNextExportPicker()
} else {
pending.deleteCache()
}
}
val view = view
if (view != null) {
view.post(enqueue)
} else if (isAdded) {
requireActivity().runOnUiThread(enqueue)
} else {
pending.deleteCache()
}
}

private fun launchNextExportPicker() {
Expand Down Expand Up @@ -754,6 +845,7 @@ internal fun writeExport(context: Context, uri: Uri, source: File): Boolean {
class WebAppInterface(
private val onExport: (content: String, filename: String, mimeType: String) -> Unit,
private val onColorScheme: (String) -> Unit = {},
private val onExportUrl: (url: String, filename: String) -> Unit = { _, _ -> },
) {
private val lock = Any()
private val buffer = StringBuilder()
Expand Down Expand Up @@ -804,4 +896,9 @@ class WebAppInterface(
fun reportColorScheme(scheme: String) {
onColorScheme(scheme)
}

@JavascriptInterface
fun exportFromUrl(url: String, filename: String) {
onExportUrl(url, filename)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ class WebUIFragmentTest {
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("Android.beginExport"))
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("Android.appendExport"))
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("Android.finishExport"))
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("Android.exportFromUrl"))
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("XMLHttpRequest.prototype"))
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("var CHUNK = $EXPORT_BRIDGE_CHUNK_SIZE;"))
assertTrue(EXPORT_BRIDGE_CHUNK_SIZE < 1024 * 1024)
assertTrue(ANDROID_EXPORT_HOOK_JS.contains("/\\.csv$/i"))
Expand Down Expand Up @@ -231,6 +233,47 @@ class WebUIFragmentTest {
assertEquals("{\"ok\":true}", file.readText())
}

@Test
fun `persistExportStream copies bytes without holding a string`() {
val payload = ByteArray(64 * 1024) { it.toByte() }
val file = persistExportStream(createTempDir(), payload.inputStream())
assertTrue(file.isFile)
assertEquals(payload.toList(), file.readBytes().toList())
}

@Test
fun `streamed export cache is deleted when it can no longer be queued`() {
val file = persistExportStream(createTempDir(), "payload".byteInputStream())
val pending = PendingExport("orphan.json", "application/json", file)
assertTrue(pending.cacheFile.isFile)
pending.deleteCache()
assertFalse(pending.cacheFile.exists())
}

@Test
fun `resolveEmbeddedExportUrl accepts loopback and relative API paths`() {
assertEquals(
"http://127.0.0.1:5600/api/0/export",
resolveEmbeddedExportUrl("/api/0/export", "http://127.0.0.1:5600/#/buckets"),
)
assertEquals(
"http://127.0.0.1:5600/api/0/buckets/aw-watcher/export",
resolveEmbeddedExportUrl("http://127.0.0.1:5600/api/0/buckets/aw-watcher/export"),
)
assertNull(resolveEmbeddedExportUrl(" "))
}

@Test
fun `WebAppInterface exportFromUrl reaches the native downloader`() {
val received = mutableListOf<Pair<String, String>>()
val bridge = WebAppInterface(
onExport = { _, _, _ -> error("string export must not run") },
onExportUrl = { url, filename -> received.add(url to filename) },
)
bridge.exportFromUrl("/api/0/export", "aw-bucket-export.json")
assertEquals(listOf("/api/0/export" to "aw-bucket-export.json"), received)
}

private fun cachedExport(dir: File, name: String, content: String): PendingExport {
return PendingExport(name, "application/json", File(dir, name).also { it.writeText(content) })
}
Expand Down
Loading