tauri-plugin-widgets 0.8.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.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject

/**
 * Redraws widgets when the app is closed: at the next scheduled `entries[].at` and when a
 * `source` is due. One inexact alarm for all placements (the earliest wins; every redraw
 * schedules the next one), so no exact-alarm permission is needed.
 */
object WidgetScheduler {
    private const val PREFS = "__tauri_widget_schedule__"
    private const val KEY_NEXT = "next_at"

    /** Earliest time this config needs a redraw after `now`, or null. */
    fun nextWakeMs(config: JSONObject, fetchedTs: Long?, now: Long, refreshMinutes: Int?): Long? {
        val entry = Resolve.nextChangeMs(config, now)
        val source = refreshMinutes?.let { (fetchedTs ?: now) + it * 60_000L }?.let { if (it <= now) now + 60_000L else it }
        return listOfNotNull(entry, source).minOrNull()
    }

    fun scheduleFor(context: Context, configRaw: String?, fetchedTs: Long?, instance: String?) {
        val config = Resolve.json(configRaw) as? JSONObject ?: return
        val now = System.currentTimeMillis()
        val refresh = Resolve.sourceRequest(config, Resolve.Context(now, currentLocaleTag(context), instance))?.refreshMinutes
        val at = nextWakeMs(config, fetchedTs, now, refresh) ?: return
        schedule(context, at)
    }

    /** Keep the earliest pending wake-up; a later one never replaces an earlier one. */
    fun schedule(context: Context, atMs: Long) {
        val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        val pending = prefs.getLong(KEY_NEXT, 0L)
        val now = System.currentTimeMillis()
        val intent = Intent(context, WidgetTickReceiver::class.java)
        // A reboot / force-stop drops alarms but not prefs: trust next_at only while the alarm exists.
        val alive = PendingIntent.getBroadcast(
            context, 0, intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
        ) != null
        if (alive && pending in (now + 1) until atMs) return
        prefs.edit().putLong(KEY_NEXT, atMs).apply()
        val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
        val pi = PendingIntent.getBroadcast(
            context, 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
        )
        am.setAndAllowWhileIdle(AlarmManager.RTC, atMs, pi) // minSdk 23
    }

    internal fun clear(context: Context) {
        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_NEXT).apply()
    }
}

/** Alarm target: redraw every Tauri widget (each redraw schedules the next alarm). */
class WidgetTickReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pending = goAsync()
        WidgetScheduler.clear(context)
        CoroutineScope(Dispatchers.Default).launch {
            try {
                TauriGlanceWidget().updateAll(context)
            } finally {
                pending.finish()
            }
        }
    }
}