tauri-plugin-widgets 0.4.3

Tauri plugin for App Widgets on Android, iOS, and macOS (WidgetKit); Windows Widgets Board (Adaptive Cards) + desktop webview; Linux desktop webview with X11 DESKTOP pin.
Documentation
package git.s00d.widgets

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import org.robolectric.RuntimeEnvironment
import java.io.File

data class Rendered(
    val bitmap: Bitmap,
    val tree: JSONObject,
)

object RenderHarness {
    private val sizeViewports = mapOf(
        "small" to (170 to 170),
        "medium" to (360 to 170),
        "large" to (360 to 380),
    )

    fun viewportFor(size: String): Pair<Int, Int> =
        sizeViewports[size] ?: (170 to 170)

    @OptIn(ExperimentalGlanceRemoteViewsApi::class)
    fun render(json: String, size: String): Rendered {
        val context = RuntimeEnvironment.getApplication()
        val (wDp, hDp) = viewportFor(size)
        val density = context.resources.displayMetrics.density
        val wPx = (wDp * density).toInt()
        val hPx = (hDp * density).toInt()

        val remoteViews = runBlocking {
            GlanceRemoteViews().compose(
                context = context,
                size = DpSize(wDp.dp, hDp.dp),
            ) {
                WidgetRootDirect(context, json, size)
            }.remoteViews
        }

        val host = FrameLayout(context)
        val view = remoteViews.apply(context, host)
        host.addView(view)

        val wSpec = View.MeasureSpec.makeMeasureSpec(wPx, View.MeasureSpec.EXACTLY)
        val hSpec = View.MeasureSpec.makeMeasureSpec(hPx, View.MeasureSpec.EXACTLY)
        host.measure(wSpec, hSpec)
        host.layout(0, 0, host.measuredWidth, host.measuredHeight)

        val bmp = Bitmap.createBitmap(
            host.measuredWidth.coerceAtLeast(1),
            host.measuredHeight.coerceAtLeast(1),
            Bitmap.Config.ARGB_8888,
        )
        host.draw(Canvas(bmp))
        return Rendered(bmp, dumpTree(host))
    }

    fun dumpTree(v: View): JSONObject {
        val obj = JSONObject()
        obj.put("kind", v.javaClass.simpleName)
        obj.put(
            "rect",
            JSONArray(listOf(v.left, v.top, v.width, v.height)),
        )
        if (v is TextView) {
            val t = v.text?.toString()?.trim().orEmpty()
            if (t.isNotEmpty()) {
                obj.put("text", t)
                obj.put("px", v.textSize.toInt())
                obj.put("color", "#%08X".format(v.currentTextColor))
            }
        }
        if (v is ViewGroup) {
            val children = JSONArray()
            for (i in 0 until v.childCount) {
                children.put(dumpTree(v.getChildAt(i)))
            }
            if (children.length() > 0) obj.put("children", children)
        }
        return obj
    }

    fun fixturesRoot(): File {
        val prop = System.getProperty("fixtures.root")
        if (!prop.isNullOrBlank()) return File(prop)
        // user.dir is android/ when running Gradle unit tests
        val fromCwd = File(System.getProperty("user.dir"), "../tests/fixtures").canonicalFile
        if (fromCwd.isDirectory) return fromCwd
        return File(RuntimeEnvironment.getApplication().filesDir, "../../../../../tests/fixtures")
            .canonicalFile
    }

    fun expectedGeometryDir(): File {
        val prop = System.getProperty("expected.geometry")
        if (!prop.isNullOrBlank()) return File(prop)
        val fromCwd = File(System.getProperty("user.dir"), "../tests/expected/geometry").canonicalFile
        if (fromCwd.isDirectory || fromCwd.parentFile?.isDirectory == true) return fromCwd
        return File(fixturesRoot().parentFile, "expected/geometry")
    }

    fun expectedPixelsDir(): File {
        val prop = System.getProperty("expected.pixels")
        if (!prop.isNullOrBlank()) return File(prop)
        return File(expectedGeometryDir().parentFile, "pixels/android")
    }

    fun updateSnapshots(): Boolean =
        System.getProperty("update.snapshots", "false").equals("true", ignoreCase = true) ||
            System.getenv("UPDATE_SNAPSHOTS") == "1"
}