Whilst I appreciate that an App Widget does not support a WebView
directly, is it at all possible to use an ImageView
(which is supported), and a technique like described here to generate the image for the ImageView
? The WebView
wouldn't be used directly, but only used in the background in order to provide the image for the ImageView
.
NB: I don't know that I would suggest this solution if the question
were asked today, now that I'm slightly less naive about this stuff. We're kinda
stuck with this answer, however, so I've updated it, and though it still uses a
somewhat hacky technique, the improvements have made it surprisingly smooth and
stable. Also, I'm pretty sure that this is basically the only way it can be done
using WebView
.
A WebView
can be laid out and drawn pretty much like any other View
, but
there are a couple of catches:
It won't render unless it's attached to a WindowManager
, whether directly or
as part of a hierarchy, and that requires the SYSTEM_ALERT_WINDOW
permission to do from the background.
Because it's attached, some of the stuff we need to do with it must happen on
the main thread in order to avoid a CalledFromWrongThreadException
.
As far as I know, there is no way to get around the permission requirement. The main thread work, however, can be minimized to a point where it's essentially negligible.
The general idea here is to add a zero-by-zero FrameLayout
with a WebView
inside it to WindowManager
, and there we can load a page and lay it out
without anything being visible on-screen.
Required permissions:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
The first things to handle in code are adding and removing our View
s with
WindowManager
. The core operation for each is wrapped in a try
-catch
so we
can use them at runtime as confirmation that the required permission is granted,
and as a quick reject should anything else prevent us from using WindowManager
.
Unqualified constants are in WindowManager.LayoutParams
:
internal suspend fun View.addToWindowManager(): Boolean =
withContext(Dispatchers.Main) {
try {
context.windowManager.addView(this@addToWindowManager, params)
true
} catch (e: Exception) {
false
}
}
private val params = WindowManager.LayoutParams(
0, 0,
when {
Build.VERSION.SDK_INT >= 26 -> TYPE_APPLICATION_OVERLAY
else -> @Suppress("DEPRECATION") TYPE_SYSTEM_OVERLAY
},
FLAG_NOT_FOCUSABLE or FLAG_NOT_TOUCHABLE,
PixelFormat.OPAQUE
)
internal suspend fun View.removeFromWindowManager(): Boolean =
withContext(Dispatchers.Main) {
try {
context.windowManager.removeView(this@removeFromWindowManager)
true
} catch (e: Exception) {
false
}
}
private inline val Context.windowManager: WindowManager
get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager
The next steps to consider are loading the web page, and determining when it's
ready to draw. You're probably already familiar with WebViewClient
and its
onPageFinished()
callback, but that's not enough to know when the page is
drawable. WebView
's postVisualStateCallback()
method is needed for that, and
we wrap it and the client setup in separate suspendCancellableCoroutine()
s.
suspend fun WebView.awaitLoadUrl(url: String): String? =
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (continuation.isActive) continuation.resume(url)
}
}
continuation.invokeOnCancellation { post { stopLoading() } }
loadUrl(url)
}
}
suspend fun WebView.awaitLayout(width: Int, height: Int) =
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
postVisualStateCallback(
0, object : WebView.VisualStateCallback() {
override fun onComplete(requestId: Long) {
if (continuation.isActive) continuation.resume(Unit)
}
}
)
layout(0, 0, width, height)
}
}
Putting everything together into an overly simplistic, static Widget:
class WebWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
val busyViews = RemoteViews(context.packageName, R.layout.widget_busy)
updateViews(appWidgetManager, appWidgetIds, busyViews)
val scope = CoroutineScope(SupervisorJob())
scope.launch {
try {
updateWidgets(context, appWidgetManager, appWidgetIds)
} catch (e: CancellationException) {
throw e
} finally {
scope.cancel()
}
}
}
private suspend fun updateWidgets(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
val bitmap = withTimeoutOrNull(9_500L) {
val frameLayout = FrameLayout(context)
when {
frameLayout.addToWindowManager() -> try {
val webView = withContext(Dispatchers.Main) {
WebView(context).also { frameLayout.addView(it) }
}
webView.awaitLoadUrl("https://stackoverflow.com/?questions")
val size = context.screenSize()
webView.awaitLayout(size.width, size.height)
webView.drawToBitmap()
} finally {
frameLayout.removeFromWindowManager()
}
else -> null
}
}
val views = when (bitmap) {
null -> RemoteViews(context.packageName, R.layout.widget_error)
else -> RemoteViews(context.packageName, R.layout.widget_image)
.apply { setImageViewBitmap(R.id.image, bitmap) }
}
updateViews(appWidgetManager, appWidgetIds, views)
}
private fun updateViews(
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
views: RemoteViews
) {
appWidgetIds.forEach { appWidgetManager.updateAppWidget(it, views) }
}
}
This minimal Widget is strictly illustrative, and really mustn't be used as is.
It should work for a quick test, as proof of concept, but there's nothing in it
to prevent the system from killing your process right after onReceive()
returns. If you provide some simple layouts for the RemoteViews
, in a "tall"
Widget, the image will look something like:
All of our helper functions are usable with Glance, too. For example, the Glance version of the minimal Widget:
private class GlanceMinimalWidget : GlanceAppWidget() {
private sealed interface State {
data object Error : State
data object Loading : State
data class Complete(val bitmap: Bitmap) : State
}
private var widgetState by mutableStateOf<State>(State.Loading)
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier
.fillMaxSize()
.background(Color.LightGray)
.appWidgetBackground()
) {
when (val state = widgetState) {
State.Error -> Text("Error")
State.Loading -> CircularProgressIndicator()
is State.Complete -> Image(
provider = ImageProvider(state.bitmap),
contentDescription = "WebShot"
)
}
}
LaunchedEffect(Unit) { update(context) }
}
}
private suspend fun update(context: Context) {
widgetState = State.Loading
val bitmap = withTimeoutOrNull(40_000L) {
val frameLayout = FrameLayout(context)
when {
frameLayout.addToWindowManager() -> try {
val webView = withContext(Dispatchers.Main) {
WebView(context).also { frameLayout.addView(it) }
}
webView.awaitLoadUrl("https://stackoverflow.com/?questions")
val size = context.screenSize()
webView.awaitLayout(size.width, size.height)
webView.drawToBitmap()
} finally {
frameLayout.removeFromWindowManager()
}
else -> null
}
}
widgetState = when (bitmap) {
null -> State.Error
else -> State.Complete(bitmap)
}
}
}
class GlanceWebWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = GlanceWebWidget()
}
Though the Glance version is a little more stable due to the supporting framework, both versions need a bit more work to be generally usable. I've put together complete examples of a few different "flavors" of Widget for each framework in a repo here: https://github.com/gonodono/web-widgets.
A final important point: the WebView
s in all of my examples, here and in the
repo, use default settings; i.e., no JavaScript, no web storage, etc. The
given routine seems to work quite robustly with those default settings, at least
on the emulators, all the way back to Nougat. I haven't done much testing with
any other configurations, but it seems that enabling certain settings might
cause WebView
to need a few more frames before everything's drawable (not
counting whatever might be required by any long-running scripts).
The platform CTS helper class that was consulted for the
postVisualStateCallback()
setup adds a ViewTreeObserver.OnDrawListener
upon the visual callback, and then invalidates to cause an extra frame before
drawing in order to ensure it's ready. Something similar is possible with this
solution, with a few adjustments, but it wasn't necessary for my basic examples.
If you do find that your setup isn't fully prepared before the draw, it might
be simpler to just add a short delay()
after the awaitLayout()
. A bit of
extra time added after a deterministic callback is certainly less shaky than the
arbitrary one-second wait from onPageFinished()
that this answer originally
used.