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
36 changes: 34 additions & 2 deletions android/src/toga_android/widgets/internal/webview.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,42 @@
import weakref

from android.webkit import WebResourceRequest, WebView as A_WebView, WebViewClient
from java import Override, jboolean, jvoid, static_proxy
from android.webkit import (
WebResourceRequest,
WebResourceResponse,
WebView as A_WebView,
WebViewClient,
)
from androidx.webkit import WebViewAssetLoader
from java import Override, dynamic_proxy, jboolean, jvoid, static_proxy
from java.io import FileInputStream
from java.lang import String as jstring

import toga


class TogaCachePathHandler(dynamic_proxy(WebViewAssetLoader.PathHandler)):
def __init__(self, impl):
super().__init__()
self.impl = impl

@Override(WebResourceResponse, [jstring])
def handle(self, path):
filepath = toga.App.app.paths.cache / path
if filepath.exists():
return WebResourceResponse(
"text/html", "utf-8", FileInputStream(str(filepath))
)
return None


class TogaWebClient(static_proxy(WebViewClient)):
def __init__(self, impl):
self._interface_ref = weakref.ref(impl.interface)
self._impl_ref = weakref.ref(impl)
pathHandler = TogaCachePathHandler(impl._native_activity)
self.cache_assetLoader = (
WebViewAssetLoader.Builder().addPathHandler("/cache/", pathHandler).build()
)
super().__init__()

@property
Expand Down Expand Up @@ -46,3 +74,7 @@ def onPageFinished(self, webview, url):
if self.impl and self.impl.loaded_future: # pragma: no-branch
self.impl.loaded_future.set_result(None)
self.impl.loaded_future = None

@Override(WebResourceResponse, [A_WebView, WebResourceRequest])
def shouldInterceptRequest(self, webview, request):
return self.cache_assetLoader.shouldInterceptRequest(request.getUrl())
32 changes: 28 additions & 4 deletions android/src/toga_android/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import hashlib
import json
import shutil
from http.cookiejar import CookieJar

from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient
from java import dynamic_proxy
from java.lang import NoClassDefFoundError

import toga
from toga.widgets.webview import CookiesResult, JavaScriptResult

from .base import Widget
Expand Down Expand Up @@ -63,6 +66,17 @@ def create(self):
self.settings.setBuiltInZoomControls(True)
self.settings.setDisplayZoomControls(False)

# folder for temporary storing content larger than 2 MB
self._large_content_dir = (
toga.App.app.paths.cache / f"toga/webview-{self.interface.id}"
)
# base URL for accessing the cached files
self._large_content_base_url = f"https://appassets.androidplatform.net/cache/toga/webview-{self.interface.id}/"

def __del__(self): # pragma: nocover
"""Cleaning up the cached files for large content"""
shutil.rmtree(self._large_content_dir, ignore_errors=True)

def get_url(self):
url = self.native.getUrl()
if url == "about:blank" or url.startswith("data:"):
Expand All @@ -77,10 +91,20 @@ def set_url(self, value, future=None):
self.native.loadUrl(value)

def set_content(self, root_url, content):
# There is a loadDataWithBaseURL method, but it's inconsistent about whether
# getUrl returns the given URL or a data: URL. Rather than support this feature
# intermittently, it's better to not support it at all.
self.native.loadData(content, "text/html", "utf-8")
if len(content) > 2 * 1024 * 1024:
self._large_content_dir.mkdir(parents=True, exist_ok=True)
h = hashlib.new("sha1")
h.update(bytes(self.interface.id, "utf-8"))
h.update(bytes(root_url, "utf-8"))
file_name = h.hexdigest() + ".html"
file_path = self._large_content_dir / file_name
file_path.write_text(content, encoding="utf-8")
self.set_url(self._large_content_base_url + file_name)
else:
# There is a loadDataWithBaseURL method, but it's inconsistent about
# whether getUrl returns the given URL or a data: URL. Rather than support
# this feature intermittently, it's better to not support it at all.
self.native.loadData(content, "text/html", "utf-8")

def get_user_agent(self):
return self.settings.getUserAgentString()
Expand Down
6 changes: 6 additions & 0 deletions android/tests_backend/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from http.cookiejar import CookieJar

from android.webkit import WebView
Expand All @@ -15,3 +16,8 @@ class WebViewProbe(SimpleProbe):
def extract_cookie(self, cookie_jar, name):
assert isinstance(cookie_jar, CookieJar)
skip("Cookie retrieval not implemented on Android")

def get_large_content_dir(self, widget):
for f in os.listdir(widget._impl._large_content_dir):
url = widget._impl._large_content_base_url + f
return url
1 change: 1 addition & 0 deletions changes/4062.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The 2MB size limit for setting static WebView content has been removed for Windows.
1 change: 1 addition & 0 deletions examples/webview/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ base_theme = "Theme.MaterialComponents.Light.DarkActionBar"

build_gradle_dependencies = [
"com.google.android.material:material:1.12.0",
"androidx.webkit:webkit:1.15.0",
]

build_gradle_extra_content="""
Expand Down
10 changes: 10 additions & 0 deletions examples/webview/webview/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ def on_set_content(self, widget, **kwargs):
),
)

def on_set_large_content(self, widget, **kwargs):
# according to the Microsoft documentation, the max content size is
# 2 MB but in fact, the limit seems to be at about 1.5 MB
large_content = f"<p>{'lorem ipsum ' * 200000}</p>"
print(f"content length: {len(large_content)}")
self.webview.set_content("https://example.com", large_content)

def on_get_agent(self, widget, **kwargs):
self.label.text = self.webview.user_agent

Expand Down Expand Up @@ -112,6 +119,9 @@ def startup(self):
toga.Button("good js", on_press=self.on_good_js),
toga.Button("bad js", on_press=self.on_bad_js),
toga.Button("set content", on_press=self.on_set_content),
toga.Button(
"set large content", on_press=self.on_set_large_content
),
],
),
toga.Box(
Expand Down
24 changes: 23 additions & 1 deletion qt/src/toga_qt/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import hashlib
import shutil
from http.cookiejar import Cookie, CookieJar

from PySide6.QtCore import QUrl, Signal
from PySide6.QtWebEngineCore import QWebEnginePage
from PySide6.QtWebEngineWidgets import QWebEngineView
from travertino.size import at_least

import toga
from toga.widgets.webview import CookiesResult, JavaScriptResult

from .base import Widget
Expand Down Expand Up @@ -84,6 +87,15 @@ def create(self):
)
self.native.loadFinished.connect(self.qt_on_webview_load)

# folder for temporary storing content larger than 2 MB
self._large_content_dir = (
toga.App.app.paths.cache / f"toga/webview-{self.interface.id}"
)

def __del__(self): # pragma: nocover
"""Cleaning up the cached files for large content"""
shutil.rmtree(self._large_content_dir, ignore_errors=True)

def qt_on_webview_load(self, ok: bool):
self.interface.on_webview_load()
if self.load_future:
Expand Down Expand Up @@ -134,7 +146,17 @@ def set_user_agent(self, value):
return self.native.page().profile().setHttpUserAgent(value)

def set_content(self, root_url, content):
self.native.setHtml(content, baseUrl=root_url)
if len(content) > 2 * 1024 * 1024:
self._large_content_dir.mkdir(parents=True, exist_ok=True)
h = hashlib.new("sha1")
h.update(bytes(self.interface.id, "utf-8"))
h.update(bytes(root_url, "utf-8"))
file_name = h.hexdigest() + ".html"
file_path = self._large_content_dir / file_name
file_path.write_text(content, encoding="utf-8")
self.set_url(file_path.as_uri())
else:
self.native.setHtml(content, baseUrl=root_url)

def get_cookies(self):
result = CookiesResult()
Expand Down
7 changes: 7 additions & 0 deletions qt/tests_backend/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from PySide6.QtWebEngineWidgets import QWebEngineView

from .base import SimpleProbe
Expand All @@ -11,3 +13,8 @@ class WebViewProbe(SimpleProbe):

def extract_cookie(self, cookie_jar, name):
return next((c for c in cookie_jar if c.name == name), None)

def get_large_content_dir(self, widget):
for f in os.listdir(widget._impl._large_content_dir):
p = widget._impl._large_content_dir / f
return p.as_uri().replace("%20", " ")
17 changes: 17 additions & 0 deletions testbed/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ requires = [
]

[tool.briefcase.app.testbed.android]
template = "https://github.com/mhsmith/briefcase-android-gradle-template"
template_branch = "pip-options-newlines"
test_requires = [
"coverage==7.13.1",
"coverage-conditional-plugin == 0.9.0",
# fonttools is only needed by Android, but we need to use sys.platform == 'linux'
# for < 3.13 there's no dependency identifier that can target Android exclusively
# before 3.13.
"fonttools==4.61.1 ; python_version < '3.13' and sys_platform == 'linux'",
"fonttools==4.61.1 ; python_version >= '3.13' and sys_platform == 'android'",
"psutil==7.2.1 ; python_version >= '3.13' and (sys_platform == 'linux' or sys_platform == 'darwin' or sys_platform == 'win32')",
"pillow==11.0.0",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-retry==1.7.0",
]
test_sources = [
"../android/tests_backend",
]
Expand All @@ -118,6 +134,7 @@ build_gradle_dependencies = [
"com.google.android.material:material:1.12.0",
"androidx.swiperefreshlayout:swiperefreshlayout:1.1.0",
"org.osmdroid:osmdroid-android:6.1.20",
"androidx.webkit:webkit:1.15.0",
]

build_gradle_extra_content = """\
Expand Down
19 changes: 19 additions & 0 deletions testbed/tests/widgets/test_webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,25 @@ async def test_static_content(widget, probe, on_load):
)


async def test_static_large_content(widget, probe, on_load):
"""Static large content can be loaded into the page"""
large_content = f"<p>{'lorem ipsum ' * 200000}</p>"
url = "https://example.com/"
widget.set_content(url, large_content)
if hasattr(probe, "get_large_content_dir"): # pragma: no branch
url = probe.get_large_content_dir(widget)

# DOM loads aren't instantaneous; wait for the URL to appear
await assert_content_change(
widget,
probe,
message="Webview has static large content",
url=url,
content=large_content,
on_load=on_load,
)


async def test_user_agent(widget, probe):
"The user agent can be customized"

Expand Down
27 changes: 25 additions & 2 deletions winforms/src/toga_winforms/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import hashlib
import json
import shutil
import webbrowser
from http.cookiejar import Cookie, CookieJar

Expand Down Expand Up @@ -102,6 +104,15 @@ def create(self):
# user on_navigation_starting handler
self._allowed_url = None

# folder for temporary storing content larger than 2 MB
self._large_content_dir = (
toga.App.app.paths.cache / f"toga/webview-{self.interface.id}"
)

def __del__(self): # pragma: nocover
"""Cleaning up the cached files for large content"""
shutil.rmtree(self._large_content_dir, ignore_errors=True)

# Any non-trivial use of the WebView requires the CoreWebView2 object to be
# initialized, which is asynchronous. Since most of this class's methods are not
# asynchronous, they cannot handle this using `await`. Instead, they add a callable
Expand Down Expand Up @@ -232,8 +243,20 @@ def set_content(self, root_url, content):
if self.interface.on_navigation_starting._raw:
# mark URL as being allowed
self._allowed_url = "about:blank"
# There appears to be no way to pass the root_url.
self.native.NavigateToString(content)
if len(content) > 1572834:
# according to the Microsoft documentation, the max content size is
# 2 MB, but in fact, the limit seems to be at about 1.5 MB
self._large_content_dir.mkdir(parents=True, exist_ok=True)
h = hashlib.new("sha1")
h.update(bytes(self.interface.id, "utf-8"))
h.update(bytes(root_url, "utf-8"))
file_name = h.hexdigest() + ".html"
file_path = self._large_content_dir / file_name
file_path.write_text(content, encoding="utf-8")
self.set_url(file_path.as_uri())
else:
# There appears to be no way to pass the root_url.
self.native.NavigateToString(content)

def get_user_agent(self):
if self.corewebview2_available:
Expand Down
7 changes: 7 additions & 0 deletions winforms/tests_backend/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from Microsoft.Web.WebView2.WinForms import WebView2

from .base import SimpleProbe
Expand All @@ -17,3 +19,8 @@ class WebViewProbe(SimpleProbe):

def extract_cookie(self, cookie_jar, name):
return next((c for c in cookie_jar if c.name == name), None)

def get_large_content_dir(self, widget):
for f in os.listdir(widget._impl._large_content_dir):
p = widget._impl._large_content_dir / f
return p.as_uri().replace("%20", " ")