Skip to content
Open
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 @@ -24,6 +24,7 @@ import java.util.Date
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
Expand All @@ -35,7 +36,7 @@ object DataUriDownloadManager {
"text/plain"
}
}
val result = writeDataUriToFile(context, url, mime)
val result = writeDataUriToFile(context, url, mime, filename)

createNotificationChannel(context)
val notification = NotificationCompat.Builder(context, CHANNEL_DOWNLOADS)
Expand Down Expand Up @@ -65,7 +66,7 @@ object DataUriDownloadManager {
.notify(url.hashCode(), notification.build())
}

private suspend fun writeDataUriToFile(context: Context, url: String, mimetype: String): Uri? =
private suspend fun writeDataUriToFile(context: Context, url: String, mimetype: String, filename: String?): Uri? =
withContext(Dispatchers.IO) {
try {
val decodedBytes = if (url.split(",")[0].endsWith("base64")) {
Expand All @@ -75,13 +76,18 @@ object DataUriDownloadManager {
Uri.decode(url.substring(url.indexOf(",") + 1)).toByteArray()
}

// URLUtil doesn't handle data URIs correctly, so we have to use a generic filename
var fileName = "Home Assistant ${SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault(),
).format(Date())}"
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimetype)?.let { extension ->
fileName += ".$extension"
val fileName = if (!filename.isNullOrBlank()) {
filename
} else {
// URLUtil doesn't handle data URIs correctly, so we have to use a generic filename
var generated = "Home Assistant ${SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault(),
).format(Date())}"
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimetype)?.let { extension ->
generated += ".$extension"
}
generated
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Expand Down Expand Up @@ -119,6 +125,8 @@ object DataUriDownloadManager {

return@withContext scanAndGetDownload(context, dataFile.absolutePath, mimetype)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Exception while writing file from data URI")
return@withContext null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,15 @@ import io.homeassistant.companion.android.webview.externalbus.NavigateTo
import io.homeassistant.companion.android.webview.externalbus.ShowSidebar
import io.homeassistant.companion.android.webview.insecure.BlockInsecureFragment
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.json.JSONObject
import timber.log.Timber

@AndroidEntryPoint
Expand Down Expand Up @@ -493,6 +496,7 @@ class WebViewActivity :
}

override fun onPageFinished(view: WebView?, url: String?) {
injectBlobInterceptor(view)
webViewInitialized.value = true
if (clearHistory) {
webView.clearHistory()
Expand Down Expand Up @@ -1964,6 +1968,8 @@ class WebViewActivity :
}
try {
request.addRequestHeader("Cookie", CookieManager.getInstance().getCookie(url))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Cannot get cookies, probably not relevant
}
Expand Down Expand Up @@ -1994,28 +2000,122 @@ class WebViewActivity :
}
}

/**
* Injects a script that intercepts [URL.createObjectURL] to keep a reference to [Blob]
* objects, so they survive [URL.revokeObjectURL] and can still be read when the WebView
* download listener fires asynchronously.
*
* Without this, blob downloads (e.g. "Download trace") fail with ERR_FILE_NOT_FOUND
* because the webpage may revoke the blob URL synchronously after clicking the anchor,
* but the WebView download listener and subsequent XHR happen asynchronously.
*
* The registry is scoped to the closure and not exposed globally. Only [window.__popBlob]
* is exposed, which requires the exact blob URL to retrieve and consume an entry,
* preventing enumeration by third-party scripts.
*/
private fun injectBlobInterceptor(view: WebView?) {
view?.evaluateJavascript(
"""
(function() {
if (window.__popBlob) return;

// Ensure registry operations cannot be overridden by capturing
// native Map methods before any other script can patch them.
var mapProto = Map.prototype;
var mapSet = Function.prototype.call.bind(mapProto.set);
var mapGet = Function.prototype.call.bind(mapProto.get);
var mapDelete = Function.prototype.call.bind(mapProto.delete);

// The registry is closure-scoped and never exposed globally,
// preventing third-party scripts from enumerating stored blobs.
var registry = new Map();

var origCreate = URL.createObjectURL.bind(URL);
URL.createObjectURL = function(obj) {
var url = origCreate(obj);
if (obj instanceof Blob) {
mapSet(registry, url, { blob: obj, filename: null });
}
return url;
};

// Intercept anchor click dispatches to capture the download
// filename before the blob URL is potentially revoked.
var origDispatch = HTMLAnchorElement.prototype.dispatchEvent;
HTMLAnchorElement.prototype.dispatchEvent = function(event) {
if (event.type === 'click' && this.href && this.href.startsWith('blob:') && this.download) {
var entry = mapGet(registry, this.href);
if (entry) {
entry.filename = this.download;
}
}
return origDispatch.call(this, event);
};

// Expose a single retrieval function that requires the exact blob
// URL. Entries are consumed on read (deleted from the registry).
// Defined as non-writable and non-configurable to prevent
// third-party scripts from replacing it with a logging wrapper.
Object.defineProperty(window, '__popBlob', {
value: function(url) {
var entry = mapGet(registry, url);
if (entry) {
mapDelete(registry, url);
return entry;
}
return null;
},
writable: false,
configurable: false,
});
})();
""".trimIndent(),
null,
)
}

/**
* Triggers a blob download by reading the blob data and passing it to the native
* [handleBlob] interface. First tries to retrieve the blob from the intercepted registry
* (see [injectBlobInterceptor]), then falls back to XHR for non-intercepted blob URLs.
*/
private fun triggerBlobDownload(url: String, contentDisposition: String, mimetype: String) {
val filename = URLUtil.guessFileName(url, contentDisposition, mimetype)
val jsCode = """
(function() {
var url = '$url';
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onload = function(e) {
if (xhr.status == 200) {
var blob = xhr.response;
var reader = new FileReader();
reader.onloadend = function() {
$javascriptInterface.handleBlob(reader.result, '$filename');
};
reader.readAsDataURL(blob);
}
};
xhr.send();
})();
""".trimIndent()
webView.evaluateJavascript(jsCode, null)
lifecycleScope.launch {
Timber.d("Triggering blob download for ${sensitive(url)}")
val fallbackFilename = withContext(Dispatchers.IO) {
URLUtil.guessFileName(url, contentDisposition, mimetype)
}
val safeUrl = JSONObject.quote(url)
val safeFallback = JSONObject.quote(fallbackFilename)
val jsCode = """
(function() {
var url = $safeUrl;
var fallbackFilename = $safeFallback;
function readAndSend(blob, filename) {
var reader = new FileReader();
reader.onloadend = function() {
$javascriptInterface.handleBlob(reader.result, filename || fallbackFilename);
};
reader.readAsDataURL(blob);
}
var entry = window.__popBlob && window.__popBlob(url);
if (entry) {
readAndSend(entry.blob, entry.filename);
} else {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status == 200) {
readAndSend(xhr.response, null);
}
};
xhr.send();
}
})();
""".trimIndent()
webView.evaluateJavascript(jsCode, null)
}
}

/**
Expand Down
Loading