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 87604e1c..2d52c383 100644 --- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt +++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt @@ -7,28 +7,205 @@ 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.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.WebView - -import android.content.Intent.ACTION_VIEW -import android.util.Log +import android.webkit.JavascriptInterface import android.webkit.URLUtil import android.webkit.WebResourceRequest +import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment import net.activitywatch.android.R +import net.activitywatch.android.ensureDashboardApiKey +import org.json.JSONObject import java.io.File import java.lang.Thread.sleep +import java.net.HttpURLConnection import java.net.URI +import java.net.URL +import java.nio.charset.StandardCharsets +import kotlin.concurrent.thread private const val TAG = "WebUI" private const val ARG_URL = "url" +// Stay under Binder's ~1 MiB transaction limit when shuttling export bodies from JS. +internal const val EXPORT_BRIDGE_CHUNK_SIZE = 256 * 1024 + +// The bundled web UI saves files with + blob: URLs. Android WebView +// does not persist those, and DownloadListener often never fires for them. +internal val ANDROID_EXPORT_HOOK_JS = """ +(function () { + if (window.__awAndroidExportHook) return; + window.__awAndroidExportHook = true; + + var blobs = Object.create(null); + window.__awAndroidBlobs = blobs; + + var createObjectURL = URL.createObjectURL.bind(URL); + var revokeObjectURL = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = function (obj) { + var url = createObjectURL(obj); + if (typeof Blob !== 'undefined' && obj instanceof Blob) { + blobs[url] = obj; + } + return url; + }; + URL.revokeObjectURL = function (url) { + delete blobs[url]; + revokeObjectURL(url); + }; + + var CHUNK = $EXPORT_BRIDGE_CHUNK_SIZE; + + function sendText(text, filename, mimeType) { + if (typeof Android === 'undefined') return; + Android.beginExport(filename, mimeType || ''); + text = String(text || ''); + for (var i = 0; i < text.length; i += CHUNK) { + Android.appendExport(text.substring(i, i + CHUNK)); + } + Android.finishExport(); + } + + window.__awAndroidSendBlob = function (url, filename) { + var blob = blobs[url]; + if (!blob) return false; + delete blobs[url]; + filename = filename || 'export'; + var reader = new FileReader(); + reader.onloadend = function () { + var mime = blob.type || (/\.csv${'$'}/i.test(filename) ? 'text/csv' : 'application/json'); + sendText(reader.result, filename, mime); + }; + reader.readAsText(blob); + return true; + }; + + document.addEventListener('click', function (event) { + var el = event.target; + while (el && el.tagName !== 'A') el = el.parentElement; + if (!el || !el.hasAttribute('download')) return; + var href = el.href; + if (!href || !blobs[href]) return; + event.preventDefault(); + event.stopPropagation(); + window.__awAndroidSendBlob(href, el.getAttribute('download') || 'export'); + }, true); +})(); +""".trimIndent() + +internal data class PendingExport( + val filename: String, + val mimeType: String, + val cacheFile: File, +) { + fun readContent(): String = cacheFile.readText(StandardCharsets.UTF_8) + + fun deleteCache() { + if (cacheFile.exists() && !cacheFile.delete()) { + Log.w(TAG, "Failed to delete export cache ${cacheFile.name}") + } + } +} + +internal data class ExportQueueSnapshot( + val items: List, + val hasInFlight: Boolean, +) + +internal const val STATE_EXPORT_PATHS = "aw_export_paths" +internal const val STATE_EXPORT_NAMES = "aw_export_names" +internal const val STATE_EXPORT_MIMES = "aw_export_mimes" +internal const val STATE_EXPORT_IN_FLIGHT = "aw_export_in_flight" + +/** Serializes Save-to pickers so a later export cannot overwrite an earlier one. */ +internal class ExportSaveQueue { + private val queue = ArrayDeque() + var inFlight: PendingExport? = null + private set + + fun enqueue(export: PendingExport) { + queue.add(export) + } + + fun beginNext(): PendingExport? { + if (inFlight != null) return null + val next = queue.firstOrNull() ?: return null + inFlight = next + return next + } + + fun completeInFlight(): PendingExport? { + val current = inFlight ?: return null + inFlight = null + if (queue.isNotEmpty() && queue.first() == current) { + queue.removeFirst() + } + return current + } + + fun snapshot(): ExportQueueSnapshot = ExportQueueSnapshot(queue.toList(), inFlight != null) + + fun restore(snapshot: ExportQueueSnapshot) { + queue.clear() + inFlight = null + val originalFirst = snapshot.items.firstOrNull() + for (item in snapshot.items) { + if (item.cacheFile.isFile) { + queue.add(item) + } + } + if (snapshot.hasInFlight && originalFirst != null && originalFirst.cacheFile.isFile) { + inFlight = queue.firstOrNull() + } + } + + val isEmpty: Boolean get() = queue.isEmpty() +} + +internal fun writeExportSnapshot(outState: Bundle, snapshot: ExportQueueSnapshot) { + outState.putStringArrayList( + STATE_EXPORT_PATHS, + ArrayList(snapshot.items.map { it.cacheFile.absolutePath }), + ) + outState.putStringArrayList( + STATE_EXPORT_NAMES, + ArrayList(snapshot.items.map { it.filename }), + ) + outState.putStringArrayList( + STATE_EXPORT_MIMES, + ArrayList(snapshot.items.map { it.mimeType }), + ) + outState.putBoolean(STATE_EXPORT_IN_FLIGHT, snapshot.hasInFlight) +} + +internal fun readExportSnapshot(state: Bundle): ExportQueueSnapshot? { + val paths = state.getStringArrayList(STATE_EXPORT_PATHS) ?: return null + val names = state.getStringArrayList(STATE_EXPORT_NAMES) ?: return null + val mimes = state.getStringArrayList(STATE_EXPORT_MIMES) ?: return null + if (paths.size != names.size || paths.size != mimes.size) { + return null + } + val items = paths.indices.map { index -> + PendingExport(names[index], mimes[index], File(paths[index])) + } + return ExportQueueSnapshot(items, state.getBoolean(STATE_EXPORT_IN_FLIGHT)) +} + +internal fun persistExportPayload(cacheDir: File, content: String): File { + val dir = File(cacheDir, "exports").apply { mkdirs() } + return File(dir, "${java.util.UUID.randomUUID()}.export").apply { + writeText(content, StandardCharsets.UTF_8) + } +} + // The embedded server lives on loopback, so keep those navigations inside the app WebView. internal fun isEmbeddedActivityWatchUrl(url: String): Boolean { val uri = try { @@ -48,6 +225,28 @@ internal fun isEmbeddedActivityWatchUrl(url: String): Boolean { } } +internal fun sanitizeExportFilename(filename: String): String { + val name = filename.substringAfterLast('/').substringAfterLast('\\').trim() + return name.takeIf { it.isNotEmpty() } ?: "export" +} + +internal fun inferExportMimeType(filename: String, explicit: String? = null): String { + val given = explicit?.trim().orEmpty() + if (given.isNotEmpty() && given != "application/octet-stream") { + return given + } + return when { + filename.endsWith(".csv", ignoreCase = true) -> "text/csv" + filename.endsWith(".json", ignoreCase = true) -> "application/json" + else -> given.ifEmpty { "application/octet-stream" } + } +} + +internal fun blobExportRecoveryJs(blobUrl: String, filename: String): String { + return "window.__awAndroidSendBlob && window.__awAndroidSendBlob(" + + "${JSONObject.quote(blobUrl)}, ${JSONObject.quote(filename)});" +} + /** * A simple [Fragment] subclass. * Activities that contain this fragment must implement the @@ -60,6 +259,35 @@ internal fun isEmbeddedActivityWatchUrl(url: String): Boolean { class WebUIFragment : Fragment() { // TODO: Rename and change types of parameters private var listener: OnFragmentInteractionListener? = null + private var webView: WebView? = null + private val exportQueue = ExportSaveQueue() + + private val createDocumentLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("*/*") + ) { uri -> + onExportDocumentCreated(uri) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + savedInstanceState?.let { state -> + readExportSnapshot(state)?.let { exportQueue.restore(it) } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + writeExportSnapshot(outState, exportQueue.snapshot()) + } + + override fun onStart() { + super.onStart() + // Recreating during an async write restores waiting items with no in-flight + // picker. Resume them here; if a picker is still open, inFlight is set. + if (exportQueue.inFlight == null) { + launchNextExportPicker() + } + } @SuppressLint("SetJavaScriptEnabled") override fun onCreateView( @@ -76,6 +304,7 @@ class WebUIFragment : Fragment() { } val myWebView: WebView = view.findViewById(R.id.webview) as WebView + webView = myWebView class MyWebViewClient : WebViewClient() { override fun onReceivedError( @@ -93,6 +322,10 @@ class WebUIFragment : Fragment() { } } + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(ANDROID_EXPORT_HOOK_JS, null) + } + // Open external links in external browser override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val url = request?.url.toString() @@ -113,15 +346,13 @@ class WebUIFragment : Fragment() { } myWebView.webViewClient = MyWebViewClient() - myWebView.setDownloadListener { url, _, _, _, _ -> - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(url) - startActivity(i) + myWebView.setDownloadListener { url, _, contentDisposition, mimeType, _ -> + handleWebViewDownload(url, contentDisposition, mimeType) } myWebView.settings.javaScriptEnabled = true myWebView.settings.domStorageEnabled = true - myWebView.addJavascriptInterface(WebAppInterface(requireContext()), "Android") + myWebView.addJavascriptInterface(WebAppInterface(::queueExport), "Android") arguments?.let { it.getString(ARG_URL)?.let { it1 -> myWebView.loadUrl(it1) } } @@ -129,6 +360,181 @@ class WebUIFragment : Fragment() { return view } + override fun onDestroyView() { + webView = null + super.onDestroyView() + } + + private fun handleWebViewDownload(url: String?, contentDisposition: String?, mimeType: String?) { + if (url.isNullOrBlank()) { + return + } + Log.i(TAG, "DownloadListener: $url") + val suggestedName = URLUtil.guessFileName(url, contentDisposition, mimeType) + when { + url.startsWith("blob:") -> { + webView?.evaluateJavascript(blobExportRecoveryJs(url, suggestedName), null) + } + isEmbeddedActivityWatchUrl(url) -> { + downloadEmbeddedExport(url, suggestedName, mimeType) + } + else -> { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No app to open $url", e) + showExportToast(getString(R.string.export_save_failed), long = true) + } + } + } + } + + private fun downloadEmbeddedExport(url: String, filename: String, mimeType: String?) { + val token = context?.let { ensureDashboardApiKey(it) }.orEmpty() + thread(name = "aw-export-fetch") { + val result = runCatching { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 60_000 + instanceFollowRedirects = true + if (token.isNotEmpty()) { + setRequestProperty("Authorization", "Bearer $token") + } + } + try { + val code = connection.responseCode + if (code !in 200..299) { + error("export HTTP $code") + } + connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } 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) + }, + ) + } + } + } + + private fun queueExport(content: String, filename: String, mimeType: String) { + val safeName = sanitizeExportFilename(filename) + val resolvedMime = inferExportMimeType(safeName, mimeType) + Log.i(TAG, "Export save requested: $safeName ($resolvedMime, ${content.length} chars)") + val cacheDir = context?.applicationContext?.cacheDir ?: return + val pending = try { + PendingExport(safeName, resolvedMime, persistExportPayload(cacheDir, content)) + } catch (e: Exception) { + Log.e(TAG, "Failed to persist export payload", e) + val notify = { + showExportToast(getString(R.string.export_save_failed), long = true) + } + view?.post(notify) ?: if (isAdded) requireActivity().runOnUiThread(notify) else Unit + return + } + val enqueue = { + 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() { + if (!isAdded) return + val next = exportQueue.beginNext() ?: return + try { + createDocumentLauncher.launch(next.filename) + } catch (e: Exception) { + Log.e(TAG, "CreateDocument failed, falling back to share sheet", e) + exportQueue.completeInFlight() + shareExport(next) + next.deleteCache() + launchNextExportPicker() + } + } + + private fun onExportDocumentCreated(uri: Uri?) { + val pending = exportQueue.completeInFlight() + if (uri != null && pending != null) { + val appContext = context?.applicationContext + if (appContext == null) { + pending.deleteCache() + launchNextExportPicker() + return + } + thread(name = "aw-export-write") { + val ok = writeExport(appContext, uri, pending.cacheFile) + pending.deleteCache() + val activity = activity ?: return@thread + activity.runOnUiThread { + if (ok) { + showExportToast(getString(R.string.export_saved, pending.filename)) + } else { + showExportToast(getString(R.string.export_save_failed), long = true) + } + launchNextExportPicker() + } + } + return + } + pending?.deleteCache() + launchNextExportPicker() + } + + private fun shareExport(pending: PendingExport) { + val ctx = context ?: return + val externalDir = ctx.getExternalFilesDir(null) ?: run { + Log.e(TAG, "External files directory unavailable") + showExportToast(getString(R.string.export_save_failed), long = true) + return + } + val file = File(externalDir, pending.filename) + try { + file.writeText(pending.readContent()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write export file: ${e.message}") + showExportToast(getString(R.string.export_save_failed), long = true) + return + } + val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.provider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = pending.mimeType + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, pending.filename) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + startActivity(Intent.createChooser(intent, pending.filename)) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No app to share ${pending.mimeType}", e) + showExportToast(getString(R.string.export_save_failed), long = true) + } + } + + private fun showExportToast(message: String, long: Boolean = false) { + val ctx = context ?: return + Toast.makeText(ctx, message, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() + } + override fun onAttach(context: Context) { super.onAttach(context) if (context is OnFragmentInteractionListener) { @@ -171,44 +577,63 @@ class WebUIFragment : Fragment() { } } -class WebAppInterface(private val mContext: Context) { +internal fun writeExport(context: Context, uri: Uri, source: File): Boolean { + return try { + context.contentResolver.openOutputStream(uri)?.use { out -> + source.inputStream().use { input -> input.copyTo(out) } + out.flush() + } != null + } catch (e: Exception) { + Log.e(TAG, "Failed to write export", e) + false + } +} + +class WebAppInterface( + private val onExport: (content: String, filename: String, mimeType: String) -> Unit, +) { + private val lock = Any() + private val buffer = StringBuilder() + private var filename: String = "export" + private var mimeType: String = "application/json" + @JavascriptInterface fun downloadCSV(csv: String, filename: String) { - downloadFile(csv, filename, "text/csv") + onExport(csv, filename, "text/csv") } @JavascriptInterface fun downloadJSON(json: String, filename: String) { - downloadFile(json, filename, "application/json") + onExport(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 + @JavascriptInterface + fun beginExport(filename: String, mimeType: String) { + synchronized(lock) { + buffer.setLength(0) + this.filename = sanitizeExportFilename(filename) + this.mimeType = inferExportMimeType(this.filename, mimeType) } - val file = File(externalDir, safeName) - try { - file.writeText(content) - } catch (e: Exception) { - Log.e(TAG, "Failed to write export file: ${e.message}") - return + } + + @JavascriptInterface + fun appendExport(chunk: String) { + synchronized(lock) { + buffer.append(chunk) } - // 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) + } + + @JavascriptInterface + fun finishExport() { + val content: String + val name: String + val mime: String + synchronized(lock) { + content = buffer.toString() + name = filename + mime = mimeType + buffer.setLength(0) } + onExport(content, name, mime) } } diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 3f7fb466..18a97d65 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -43,4 +43,6 @@ No browser app available to open this URL + Saved %1$s + Could not save export 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 be27ad8f..1bf9c408 100644 --- a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt @@ -1,8 +1,11 @@ package net.activitywatch.android.fragments +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class WebUIFragmentTest { @Test @@ -25,4 +28,179 @@ class WebUIFragmentTest { assertFalse(isEmbeddedActivityWatchUrl("http://192.168.1.10:5600")) assertFalse(isEmbeddedActivityWatchUrl("not a url")) } + + @Test + fun `sanitizeExportFilename strips path components`() { + assertEquals("aw-bucket-export.json", sanitizeExportFilename("../../aw-bucket-export.json")) + assertEquals("events.csv", sanitizeExportFilename("C:\\temp\\events.csv")) + assertEquals("export", sanitizeExportFilename(" ")) + assertEquals("export", sanitizeExportFilename("")) + assertEquals("aw-bucket-export-foo.json", sanitizeExportFilename("aw-bucket-export-foo.json")) + } + + @Test + fun `inferExportMimeType prefers explicit type then filename`() { + assertEquals("application/json", inferExportMimeType("export.bin", "application/json")) + assertEquals("text/csv", inferExportMimeType("events.CSV", "application/octet-stream")) + assertEquals("application/json", inferExportMimeType("bucket.json", null)) + assertEquals("application/octet-stream", inferExportMimeType("export", null)) + } + + @Test + fun `export hook js intercepts blob downloads and chunks through the bridge`() { + assertTrue(ANDROID_EXPORT_HOOK_JS.contains("URL.createObjectURL")) + assertTrue(ANDROID_EXPORT_HOOK_JS.contains("URL.revokeObjectURL")) + assertTrue(ANDROID_EXPORT_HOOK_JS.contains("delete blobs[url]")) + 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("var CHUNK = $EXPORT_BRIDGE_CHUNK_SIZE;")) + assertTrue(EXPORT_BRIDGE_CHUNK_SIZE < 1024 * 1024) + assertTrue(ANDROID_EXPORT_HOOK_JS.contains("/\\.csv$/i")) + } + + @Test + fun `blob recovery js quotes hostile filenames`() { + val js = blobExportRecoveryJs("blob:http://127.0.0.1/abc", "aw-export\".js") + assertTrue(js.startsWith("window.__awAndroidSendBlob && window.__awAndroidSendBlob(")) + assertTrue(js.contains("blob:http://127.0.0.1/abc")) + assertTrue(js.contains("\\u0022") || js.contains("\\\"")) + assertFalse(js.contains("aw-export\".js")) + } + + @Test + fun `WebAppInterface reassembles chunked exports`() { + var received: Triple? = null + val bridge = WebAppInterface { content, filename, mimeType -> + received = Triple(content, filename, mimeType) + } + + bridge.beginExport("../aw-bucket-export.json", "application/json") + bridge.appendExport("{\"buckets\":") + bridge.appendExport("[1,2,3]}") + bridge.finishExport() + + assertEquals(Triple("{\"buckets\":[1,2,3]}", "aw-bucket-export.json", "application/json"), received) + } + + @Test + fun `WebAppInterface download helpers keep explicit mime types`() { + val received = mutableListOf>() + val bridge = WebAppInterface { content, filename, mimeType -> + received.add(Triple(content, filename, mimeType)) + } + + bridge.downloadJSON("{}", "data.json") + bridge.downloadCSV("a,b", "data.csv") + + assertEquals( + listOf( + Triple("{}", "data.json", "application/json"), + Triple("a,b", "data.csv", "text/csv"), + ), + received, + ) + } + + @Test + fun `export queue keeps the first picker payload when a second export arrives`() { + val dir = createTempDir() + val first = cachedExport(dir, "first.json", "one") + val second = cachedExport(dir, "second.json", "two") + val queue = ExportSaveQueue() + + queue.enqueue(first) + assertEquals(first, queue.beginNext()) + queue.enqueue(second) + assertEquals(null, queue.beginNext()) + assertEquals(first, queue.inFlight) + + assertEquals(first, queue.completeInFlight()) + assertEquals(second, queue.beginNext()) + assertEquals(second, queue.completeInFlight()) + assertEquals(null, queue.beginNext()) + assertTrue(queue.isEmpty) + } + + @Test + fun `cancelling the first picker still offers the next queued export`() { + val dir = createTempDir() + val first = cachedExport(dir, "first.json", "one") + val second = cachedExport(dir, "second.json", "two") + val queue = ExportSaveQueue() + + queue.enqueue(first) + queue.beginNext() + queue.enqueue(second) + queue.completeInFlight() + + assertEquals(second, queue.beginNext()) + } + + @Test + fun `queue snapshot restore keeps the in-flight export first`() { + val dir = createTempDir() + val first = cachedExport(dir, "first.json", "one") + val second = cachedExport(dir, "second.json", "two") + val original = ExportSaveQueue() + original.enqueue(first) + original.beginNext() + original.enqueue(second) + + val restored = ExportSaveQueue() + restored.restore(original.snapshot()) + + assertEquals(first, restored.inFlight) + assertEquals(first, restored.completeInFlight()) + assertEquals(second, restored.beginNext()) + assertEquals("one", first.readContent()) + assertEquals("two", second.readContent()) + } + + @Test + fun `restoring after the in-flight write started can begin the next waiting export`() { + val dir = createTempDir() + val first = cachedExport(dir, "first.json", "one") + val second = cachedExport(dir, "second.json", "two") + val original = ExportSaveQueue() + original.enqueue(first) + original.beginNext() + original.enqueue(second) + original.completeInFlight() + + val restored = ExportSaveQueue() + restored.restore(original.snapshot()) + + assertNull(restored.inFlight) + assertEquals(second, restored.beginNext()) + } + + @Test + fun `restore drops a missing in-flight cache instead of delivering the next export`() { + val dir = createTempDir() + val first = cachedExport(dir, "first.json", "one") + val second = cachedExport(dir, "second.json", "two") + val original = ExportSaveQueue() + original.enqueue(first) + original.beginNext() + original.enqueue(second) + first.cacheFile.delete() + + val restored = ExportSaveQueue() + restored.restore(original.snapshot()) + + assertNull(restored.inFlight) + assertEquals(second, restored.beginNext()) + } + + @Test + fun `persistExportPayload writes content that can be read back`() { + val file = persistExportPayload(createTempDir(), "{\"ok\":true}") + assertTrue(file.isFile) + assertEquals("{\"ok\":true}", file.readText()) + } + + private fun cachedExport(dir: File, name: String, content: String): PendingExport { + return PendingExport(name, "application/json", File(dir, name).also { it.writeText(content) }) + } }