tauri-plugin-widgets 0.5.0

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.content.Intent
import android.net.Uri
import android.util.Log
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.AppWidgetId
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import org.json.JSONArray
import org.json.JSONObject

private const val CONFIG_STATE_KEY_NAME = "__widget_config_state__"
private const val NONCE_STATE_KEY_NAME = "__widget_nonce_state__"
internal val CONFIG_STATE_KEY = stringPreferencesKey(CONFIG_STATE_KEY_NAME)
internal val NONCE_STATE_KEY = stringPreferencesKey(NONCE_STATE_KEY_NAME)

internal val ACTION_KEY = ActionParameters.Key<String>("action")
internal val PAYLOAD_KEY = ActionParameters.Key<String>("payload")
internal val URL_KEY = ActionParameters.Key<String>("url")

class WidgetActionCallback : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: androidx.glance.GlanceId,
        parameters: ActionParameters,
    ) {
        val appWidgetId = (glanceId as? AppWidgetId)?.appWidgetId ?: -1
        val group = WidgetStoreKeys.resolveGroup(context, appWidgetId)
        val action = parameters[ACTION_KEY]
        val payload = parameters[PAYLOAD_KEY]
        val url = parameters[URL_KEY]
        val normalizedUrl = url?.trim().orEmpty().let {
            if (it.equals("null", ignoreCase = true) || it.equals("undefined", ignoreCase = true)) "" else it
        }

        // Action-driven buttons are the primary flow in widgets.
        // Only attempt URL open when no action is provided.
        if (!action.isNullOrBlank()) {
            val logicalWidgetId = WidgetStoreKeys.resolveWidgetId(context, appWidgetId)
            val toggled = applyLocalListToggleIfNeeded(
                context, glanceId, group, logicalWidgetId, action, payload,
            )
            if (toggled) {
                Log.d(TAG, "action local list toggle applied action=$action")
            }
            val plugin = WidgetBridgePlugin.pluginInstance
            if (plugin != null) {
                plugin.emitWidgetAction(action, payload, logicalWidgetId, group)
            } else {
                // Queue only when the host plugin is unavailable (matches WidgetActionReceiver).
                val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE)
                val pendingRaw = prefs.getString(WidgetStoreKeys.PENDING_ACTIONS, "[]") ?: "[]"
                val arr = runCatching { JSONArray(pendingRaw) }.getOrElse { JSONArray() }
                val obj = JSONObject()
                obj.put("action", action)
                if (!payload.isNullOrBlank()) obj.put("payload", payload)
                obj.put("ts", System.currentTimeMillis())
                obj.put("widgetId", logicalWidgetId)
                obj.put("group", group)
                arr.put(obj)
                prefs.edit().putString(WidgetStoreKeys.PENDING_ACTIONS, arr.toString()).apply()
            }
        } else if (normalizedUrl.isNotBlank()) {
            val uri = Uri.parse(normalizedUrl)
            val intent = Intent(Intent.ACTION_VIEW, uri).apply {
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            val canHandle = intent.resolveActivity(context.packageManager) != null
            if (canHandle) {
                runCatching { context.startActivity(intent) }
                    .onFailure { e -> Log.e(TAG, "action open url failed=${e.message} url=$normalizedUrl", e) }
            } else {
                Log.w(TAG, "action open url skipped: no handler url=$normalizedUrl")
            }
        }

        runCatching { TauriGlanceWidget().update(context, glanceId) }
            .onFailure { e -> Log.e(TAG, "action-triggered update failed=${e.message}", e) }
    }

    private suspend fun applyLocalListToggleIfNeeded(
        context: Context,
        glanceId: androidx.glance.GlanceId,
        group: String,
        logicalWidgetId: String,
        action: String,
        payload: String?,
    ): Boolean {
        val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE)
        val raw = prefs.getString(WidgetStoreKeys.configKey(logicalWidgetId), null) ?: return false
        val root = runCatching { JSONObject(raw) }.getOrNull() ?: return false
        var changed = false
        listOf("small", "medium", "large").forEach { key ->
            changed = toggleListItemsInElement(root.optJSONObject(key), action, payload) || changed
        }
        if (!changed) return false
        val updated = root.toString()
        prefs.edit().putString(WidgetStoreKeys.configKey(logicalWidgetId), updated).apply()
        runCatching {
            updateAppWidgetState(context, glanceId) { state ->
                state[CONFIG_STATE_KEY] = updated
                state[NONCE_STATE_KEY] = System.currentTimeMillis().toString()
            }
        }.onFailure { e ->
            Log.e(TAG, "local toggle update state failed=${e.message}", e)
        }
        return true
    }

    private fun toggleListItemsInElement(el: JSONObject?, action: String, payload: String?): Boolean {
        if (el == null) return false
        var changed = false
        if (el.widgetString("type", "") == "list") {
            val items = el.optJSONArray("items") ?: JSONArray()
            for (i in 0 until items.length()) {
                val item = items.optJSONObject(i) ?: continue
                if (item.widgetString("action", "") != action) continue
                if (!payload.isNullOrBlank()) {
                    val itemPayload = item.widgetString("payload", "")
                    if (itemPayload != payload) continue
                }
                val hasChecked = item.has("checked")
                val hasIsOn = item.has("isOn")
                if (!hasChecked && !hasIsOn) continue
                val current = item.optBoolean("checked", item.optBoolean("isOn", false))
                val next = !current
                if (hasChecked || !hasIsOn) item.put("checked", next)
                if (hasIsOn) item.put("isOn", next)
                changed = true
            }
        }
        val children = el.optJSONArray("children")
        if (children != null) {
            for (i in 0 until children.length()) {
                changed = toggleListItemsInElement(children.optJSONObject(i), action, payload) || changed
            }
        }
        return changed
    }
}