androidwebviewimageviewandroid-appwidgetappwidgetprovider

Android: loading WebView output into App Widget


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.


Solution

  • 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:

    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 Views 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:

    Screenshot of an emulator showing the above Widget

    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 WebViews 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.