package community.yuvomi.mobile import android.content.ContentResolver import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Base64 import android.webkit.WebView import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import java.io.OutputStream import java.util.UUID import java.util.WeakHashMap import java.util.concurrent.Executor import org.json.JSONObject /** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */ object BlobTransfer { private val stores = WeakHashMap() fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5)) fun install(web: WebView, server: ServerAddress) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }" stores[web] = key val script = """ (function(){if(window.top!==window)return; const entries=new Map();let total=0;const limit=25*1024*1024; const original=URL.createObjectURL.bind(URL); URL.createObjectURL=function(blob){const url=original(blob); if(blob instanceof Blob && blob.size<=limit){ while(entries.size && (total+blob.size>limit || entries.size>=8)){ const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first); } entries.set(url,blob);total+=blob.size; }return url;}; Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){ const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob; }}}); })(); """.trimIndent() // Retain a bounded Blob reference even if upstream revokes the object URL // immediately after clicking its download anchor. Never fetch a blob URL: // upstream's CSP correctly refuses that operation in a WebView. WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin)) } fun forget(web: WebView) { stores.remove(web) } fun save( server: ServerAddress, web: WebView, value: String, resolver: ContentResolver, destination: Uri, worker: Executor, authorized: () -> Boolean, completed: (Boolean) -> Unit, ) { val store = stores[web] if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) { completed(false) return } val handler = Handler(Looper.getMainLooper()) val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}") val maxBytes = 25 * 1024 * 1024 var output: OutputStream? = null var offset = 0 var finished = false lateinit var expiry: Runnable fun allowed() = authorized() && server.owns(web.url.orEmpty()) fun finish(success: Boolean) { if (finished) return finished = true handler.removeCallbacks(expiry) if (runCatching { allowed() }.getOrDefault(false)) { runCatching { web.evaluateJavascript("delete window[$key]", null) } } val close = Runnable { val closed = runCatching { output?.close() }.isSuccess handler.post { completed(success && closed) } } runCatching { worker.execute(close) }.onFailure { close.run() } } expiry = Runnable { finish(false) } lateinit var pull: Runnable pull = Runnable { if (!runCatching { allowed() }.getOrDefault(false)) { finish(false) return@Runnable } val script = """ (function(){const x=window[$key]; if(!x || x.state!=='ready')return {state:x?x.state:'failed'}; const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'}; const end=Math.min($offset+32768,b.length); return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})() """.trimIndent() runCatching { web.evaluateJavascript(script) { result -> if (finished) return@evaluateJavascript val data = runCatching { require(result.length < 50_000) JSONObject(result) }.getOrNull() when (data?.optString("state")) { "loading" -> handler.postDelayed(pull, 50) "ready" -> { val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull() if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) { finish(false) } else { offset += chunk.size runCatching { worker.execute { val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess handler.post { if (!success) { finish(false) } else if (data.optBoolean("done")) { finish(true) } else { handler.post(pull) } } } }.onFailure { finish(false) } } } else -> finish(false) } } }.onFailure { finish(false) } } handler.postDelayed(expiry, 45_000) runCatching { worker.execute { output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull() handler.post { if (finished) { runCatching { output?.close() } return@post } if (output == null || !allowed()) { finish(false) return@post } val script = """ (function(){window[$key]={state:'loading'}; try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)}); if(!b || b.size>$maxBytes)throw new Error('size'); b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)}) .catch(()=>window[$key]={state:'failed'}); }catch(e){window[$key]={state:'failed'};}})() """.trimIndent() runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) } } } }.onFailure { finish(false) } } }