SHA256
feat(android): replace WebView with tested native Compose API slice
Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
This commit is contained in:
1 parent
81973c3c45
commit
8c480ddca3
28 files changed
+2628
-742
No files matched your search
@@ -0,0 +1,165 @@
|
||||
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<WebView, String>()
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user