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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
package git.s00d.widgets

import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject

/**
 * Live activities on Android: an ongoing notification (title, body, progress, countdown
 * chip, up to three actions). Android 16 promotes it to a Live Update when the user allows
 * it (`android.requestPromotedOngoing`). Taps on actions are queued like widget taps with
 * `widgetId = "activity.<id>"`.
 */
object ActivityNotifications {
    const val CHANNEL_ID = "tauri_widget_activities"
    private const val PREFS = "__tauri_widget_activities__"
    private const val KEY_NEXT_ID = "__next_id__"
    /** Notification ids used by this plugin start here (stays clear of small app ids). */
    private const val ID_BASE = 0x7A110000
    const val EXTRA_ID = "git.s00d.widgets.activity.id"
    const val EXTRA_ACTION = "git.s00d.widgets.activity.action"
    const val EXTRA_GROUP = "git.s00d.widgets.activity.group"

    private fun prefs(c: Context) = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE)

    /** Stable notification id per activity id; stored, so ids never collide. */
    fun notificationId(c: Context, id: String): Int {
        val p = prefs(c)
        val existing = p.getInt("id:$id", -1)
        if (existing >= 0) return existing
        val next = p.getInt(KEY_NEXT_ID, 0)
        val nid = ID_BASE + next
        p.edit().putInt("id:$id", nid).putInt(KEY_NEXT_ID, (next + 1) % 0xFFFF).commit()
        return nid
    }

    fun canPost(c: Context): Boolean =
        Build.VERSION.SDK_INT < 33 ||
            c.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED

    private fun ensureChannel(c: Context) {
        if (Build.VERSION.SDK_INT < 26) return
        val nm = c.getSystemService(NotificationManager::class.java) ?: return
        if (nm.getNotificationChannel(CHANNEL_ID) != null) return
        val label = runCatching { c.applicationInfo.loadLabel(c.packageManager).toString() }.getOrDefault("Activities")
        nm.createNotificationChannel(
            NotificationChannel(CHANNEL_ID, label, NotificationManager.IMPORTANCE_DEFAULT).apply {
                setSound(null, null)
                enableVibration(false)
            }
        )
    }

    /**
     * Status-bar icon must be a monochrome drawable: the launcher icon (often adaptive)
     * renders as a white square and can crash on API 26. Apps ship `tauri_activity_icon`.
     */
    @android.annotation.SuppressLint("DiscouragedApi") // name lookup: the resource lives in the app, not here
    private fun smallIcon(c: Context): Int =
        c.resources.getIdentifier("tauri_activity_icon", "drawable", c.packageName)
            .takeIf { it != 0 } ?: android.R.drawable.stat_notify_more

    /**
     * Contract v3 §3: `template` names a layout stored under `activity-template:{name}`; its
     * strings are resolved with `data` (`{{data.x}}`, `when`, `repeat`, …) and fields set
     * directly on the content win. Unknown template: the content as sent.
     */
    internal fun expandTemplate(c: Context, content: JSONObject, group: String): JSONObject {
        val name = content.opt("template") as? String ?: return content
        val prefs = c.getSharedPreferences(WidgetSanitizer.sanitizeGroup(group, c.packageName), Context.MODE_PRIVATE)
        val raw = prefs.getString(WidgetSanitizer.sanitizeKey(WidgetStoreKeys.ACTIVITY_TEMPLATE_PREFIX + name), null)
        val tpl = Resolve.json(raw) as? JSONObject ?: return content
        val ctx = Resolve.Context(System.currentTimeMillis(), currentLocaleTag(c), data = content.opt("data"))
        val out = Resolve.resolveValue(tpl, ctx) as JSONObject
        content.keys().forEach { k ->
            val v = content.opt(k)
            // Like Rust `resolve_activity`: a blank `title` takes the template's.
            val blankTitle = k == "title" && (v as? String)?.isBlank() == true
            if (k != "template" && k != "data" && !blankTitle && v != JSONObject.NULL) out.put(k, v)
        }
        return out
    }

    /** Android 16: the user lets this app post promoted (Live Update) notifications. */
    fun canPostPromoted(c: Context): Boolean {
        if (Build.VERSION.SDK_INT < 36) return false
        val nm = c.getSystemService(NotificationManager::class.java) ?: return false
        return runCatching { nm.javaClass.getMethod("canPostPromotedNotifications").invoke(nm) as Boolean }.getOrDefault(false)
    }

    /** Settings page for Live Updates (API 36), else the app's notification settings. */
    fun promotionSettingsIntent(c: Context): Intent = when {
        Build.VERSION.SDK_INT >= 36 -> Intent("android.settings.APP_NOTIFICATION_PROMOTION_SETTINGS")
            .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, c.packageName)
        Build.VERSION.SDK_INT >= 26 -> Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
            .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, c.packageName)
        else -> Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${c.packageName}"))
    }

    /** ProgressStyle plan: segments (length, argb) and points (position, argb) on a 0…total track. */
    internal data class ProgressPlan(val progress: Int, val segments: List<Pair<Int, Int?>>, val points: List<Pair<Int, Int?>>)

    private fun argb(hex: Any?): Int? = (hex as? String)?.let { runCatching { android.graphics.Color.parseColor(expandShortHex(it)) }.getOrNull() }

    /**
     * `progress` (0…1) on one `tint` segment; optional `progressSegments: [{length, color}]`
     * and `progressPoints: [{at: 0…1, color}]` replace / mark the track. Null without progress.
     */
    internal fun progressPlan(content: JSONObject): ProgressPlan? {
        val segs = content.optJSONArray("progressSegments")
        if ((!content.has("progress") || content.isNull("progress")) && segs == null) return null
        val segments = (0 until (segs?.length() ?: 0)).mapNotNull { i ->
            val o = segs!!.optJSONObject(i) ?: return@mapNotNull null
            o.optInt("length", 0).takeIf { it > 0 }?.let { it to argb(o.opt("color")) }
        }.ifEmpty { listOf(1000 to argb(content.opt("tint"))) }
        val total = segments.sumOf { it.first }
        val pts = content.optJSONArray("progressPoints")
        val points = (0 until (pts?.length() ?: 0)).mapNotNull { i ->
            val o = pts!!.optJSONObject(i) ?: return@mapNotNull null
            (o.optDouble("at", 0.0).coerceIn(0.0, 1.0) * total).toInt() to argb(o.opt("color"))
        }
        val progress = (content.optDouble("progress", 0.0).coerceIn(0.0, 1.0) * total).toInt()
        return ProgressPlan(progress, segments, points)
    }

    /**
     * API 36 Live Update: rebuild the compat notification with the framework builder
     * (`setRequestPromotedOngoing`, `setShortCriticalText`, `ProgressStyle`). Reflection,
     * because the plugin compiles against an older SDK. Null when unavailable.
     */
    private fun promoted(c: Context, n: android.app.Notification, content: JSONObject): android.app.Notification? {
        if (Build.VERSION.SDK_INT < 36) return null
        return runCatching {
            val fb = android.app.Notification.Builder.recoverBuilder(c, n)
            val cls = android.app.Notification.Builder::class.java
            cls.getMethod("setRequestPromotedOngoing", Boolean::class.javaPrimitiveType).invoke(fb, true)
            content.optString("shortCriticalText").takeIf { it.isNotEmpty() }?.let {
                cls.getMethod("setShortCriticalText", String::class.java).invoke(fb, it)
            }
            progressPlan(content)?.let { plan ->
                val style = Class.forName("android.app.Notification\$ProgressStyle")
                val seg = Class.forName("android.app.Notification\$ProgressStyle\$Segment")
                val pt = Class.forName("android.app.Notification\$ProgressStyle\$Point")
                val ps = style.getConstructor().newInstance()
                val segments = plan.segments.map { (len, color) ->
                    seg.getConstructor(Int::class.javaPrimitiveType).newInstance(len).also { s ->
                        color?.let { seg.getMethod("setColor", Int::class.javaPrimitiveType).invoke(s, it) }
                    }
                }
                val points = plan.points.map { (at, color) ->
                    pt.getConstructor(Int::class.javaPrimitiveType).newInstance(at).also { p ->
                        color?.let { pt.getMethod("setColor", Int::class.javaPrimitiveType).invoke(p, it) }
                    }
                }
                style.getMethod("setProgressSegments", List::class.java).invoke(ps, segments)
                if (points.isNotEmpty()) style.getMethod("setProgressPoints", List::class.java).invoke(ps, points)
                style.getMethod("setProgress", Int::class.javaPrimitiveType).invoke(ps, plan.progress)
                fb.setStyle(ps as android.app.Notification.Style)
            }
            fb.build()
        }.onFailure { android.util.Log.w(TAG, "live update promotion unavailable: ${it.message}") }.getOrNull()
    }

    /**
     * Out-of-order guard (as iOS): an update of a running activity whose `timestamp` is older
     * than the last applied one is dropped. A fresh start always applies.
     */
    internal fun shouldApply(active: Boolean, last: Long?, ts: Long?): Boolean =
        !active || ts == null || last == null || ts >= last

    /**
     * Post or replace the notification for this request. Throws when it cannot be shown;
     * false when dropped as stale (`timestamp` older than the last applied one).
     */
    fun post(c: Context, request: JSONObject, group: String): Boolean {
        val content = expandTemplate(c, request, group)
        val id = content.optString("id")
        require(id.isNotBlank()) { "activity id must not be empty" }
        check(canPost(c)) {
            "POST_NOTIFICATIONS is not granted; ask for it first (e.g. @tauri-apps/plugin-notification requestPermission())"
        }
        val ts = content.optLong("timestamp", -1L).takeIf { content.has("timestamp") && !content.isNull("timestamp") && it >= 0 }
        val last = prefs(c).getLong("ts:$id", -1L).takeIf { it >= 0 }
        val wasActive = isActive(c, id)
        if (!shouldApply(wasActive, last, ts)) {
            android.util.Log.i(TAG, "activity $id: stale update dropped (timestamp $ts < $last)")
            return false
        }
        ensureChannel(c)
        val nid = notificationId(c, id)
        val b = NotificationCompat.Builder(c, CHANNEL_ID)
            .setSmallIcon(smallIcon(c))
            .setContentTitle(content.optString("title"))
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .setSilent(true)
            .setCategory(category(content.optString("category")))
            .setVisibility(visibility(content.optString("visibility")))
        content.optString("body").takeIf { it.isNotEmpty() }?.let { b.setContentText(it) }
        content.optString("subText").takeIf { it.isNotEmpty() }?.let { b.setSubText(it) }
        // Android 16 promoted chip text: the extra here (older SDKs / fallback); on API 36
        // [promoted] also calls `setShortCriticalText`.
        content.optString("shortCriticalText").takeIf { it.isNotEmpty() }?.let {
            b.extras.putString(EXTRA_SHORT_CRITICAL_TEXT, it)
        }
        content.optString("tint").takeIf { it.isNotEmpty() }?.let { hex ->
            runCatching { android.graphics.Color.parseColor(hex) }.getOrNull()?.let { b.setColor(it) }
        }
        if (content.has("progress") && !content.isNull("progress")) {
            val p = (content.optDouble("progress", 0.0).coerceIn(0.0, 1.0) * 1000).toInt()
            b.setProgress(1000, p, false)
        }
        if (content.has("endsAt") && !content.isNull("endsAt")) {
            b.setWhen(content.optLong("endsAt")).setShowWhen(true).setUsesChronometer(true)
            if (Build.VERSION.SDK_INT >= 24) b.setChronometerCountDown(true)
        } else if (content.has("startedAt") && !content.isNull("startedAt")) {
            // Stopwatch counting up from startedAt.
            b.setWhen(content.optLong("startedAt")).setShowWhen(true).setUsesChronometer(true)
        }
        b.setContentIntent(openApp(c, content.optString("url").takeIf { it.isNotEmpty() }, nid))
        val actions = content.optJSONArray("actions")
        if (actions != null) {
            for (i in 0 until minOf(actions.length(), 3)) {
                val a = actions.optJSONObject(i) ?: continue
                val url = a.optString("url").takeIf { it.isNotEmpty() && it != "null" }
                // `url`: open the deep link instead of queuing a widget-action.
                val pi = if (url != null) {
                    openApp(c, url, nid * 4 + i)
                } else {
                    val intent = Intent(c, ActivityActionReceiver::class.java)
                        .putExtra(EXTRA_ID, id)
                        .putExtra(EXTRA_ACTION, a.optString("id"))
                        .putExtra(EXTRA_GROUP, group)
                    PendingIntent.getBroadcast(
                        c, nid * 4 + i, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
                    )
                }
                val icon = a.optString("systemName").takeIf { it.isNotEmpty() && it != "null" }
                    ?.let { actionIcon(c, it) } ?: 0
                b.addAction(icon, a.optString("label"), pi)
            }
        }
        // Android 16 Live Updates: ask for promotion; the system decides with the user setting.
        b.extras.putBoolean("android.requestPromotedOngoing", true)
        val vis = content.optString("visibility")
        // Lock screen: `private` shows a redacted copy (app name, timer, progress; no text,
        // no actions); `secret` hides it (no public version needed).
        if (vis == "private") b.setPublicVersion(publicVersion(c, content))
        if (isSamsung() && Build.VERSION.SDK_INT < 36) {
            samsungExtras(content, appLabel(c), android.graphics.drawable.Icon.createWithResource(c, smallIcon(c)))
                ?.let { b.addExtras(it) }
        }
        val built = b.build()
        NotificationManagerCompat.from(c).notify(nid, promoted(c, built, content) ?: built)
        prefs(c).edit().putString("active:$id", group).apply {
            // No timestamp: keep the last one (iOS does too); a fresh start forgets an old one.
            if (ts != null) putLong("ts:$id", ts) else if (!wasActive) remove("ts:$id")
        }.apply()
        return true
    }

    private fun appLabel(c: Context): String =
        runCatching { c.applicationInfo.loadLabel(c.packageManager).toString() }.getOrDefault("")

    /** Redacted lock-screen copy for `visibility: private`: app name, timer and progress only. */
    internal fun publicVersion(c: Context, content: JSONObject): android.app.Notification {
        val p = NotificationCompat.Builder(c, CHANNEL_ID)
            .setSmallIcon(smallIcon(c))
            .setContentTitle(appLabel(c))
            .setOngoing(true)
            .setSilent(true)
            .setCategory(category(content.optString("category")))
        argb(content.opt("tint"))?.let { p.setColor(it) }
        if (content.has("progress") && !content.isNull("progress")) {
            p.setProgress(1000, (content.optDouble("progress", 0.0).coerceIn(0.0, 1.0) * 1000).toInt(), false)
        }
        if (content.has("endsAt") && !content.isNull("endsAt")) {
            p.setWhen(content.optLong("endsAt")).setShowWhen(true).setUsesChronometer(true)
            if (Build.VERSION.SDK_INT >= 24) p.setChronometerCountDown(true)
        } else if (content.has("startedAt") && !content.isNull("startedAt")) {
            p.setWhen(content.optLong("startedAt")).setShowWhen(true).setUsesChronometer(true)
        }
        return p.build()
    }

    internal fun isSamsung(): Boolean = Build.MANUFACTURER.equals("samsung", ignoreCase = true)

    /**
     * Samsung One UI 7 Live Notifications / Now Bar (`android.ongoingActivityNoti.*`, read
     * with the `com.samsung.android.support.ongoing_activity` manifest flag; Samsung shows
     * them only for allow-listed apps). Applied below Android 16 only: on One UI 8+ the
     * Now Bar takes AOSP Live Updates, and `style >= 1` would move the notification off that
     * path. `secret` gets none (the Now Bar is on the lock screen); `private` shows the app
     * name there. Null when there is nothing to show.
     */
    internal fun samsungExtras(content: JSONObject, appLabel: String, chipIcon: android.graphics.drawable.Icon?): android.os.Bundle? {
        val vis = content.optString("visibility")
        val title = content.optString("title")
        if (vis == "secret" || title.isBlank()) return null
        val body = content.optString("body").takeIf { it.isNotEmpty() && it != "null" }
        val chip = content.optString("shortCriticalText").takeIf { it.isNotEmpty() && it != "null" } ?: title
        return android.os.Bundle().apply {
            putInt(SAMSUNG + "style", 1)
            putString(SAMSUNG + "primaryInfo", title)
            body?.let { putString(SAMSUNG + "secondaryInfo", it) }
            putString(SAMSUNG + "chipExpandedText", chip)
            chipIcon?.let { putParcelable(SAMSUNG + "chipIcon", it) }
            argb(content.opt("tint"))?.let {
                putInt(SAMSUNG + "chipBgColor", it)
                putInt(SAMSUNG + "progressSegments.progressColor", it)
            }
            if (content.has("progress") && !content.isNull("progress")) {
                putInt(SAMSUNG + "progress", (content.optDouble("progress", 0.0).coerceIn(0.0, 1.0) * 100).toInt())
                putInt(SAMSUNG + "progressMax", 100)
            }
            if ((content.optJSONArray("actions")?.length() ?: 0) > 0) {
                putInt(SAMSUNG + "actionType", 1)
                putInt(SAMSUNG + "actionPrimarySet", 0)
            }
            if (vis == "private") {
                putString(SAMSUNG + "nowbarPrimaryInfo", appLabel)
                putString(SAMSUNG + "nowbarSecondaryInfo", "")
            }
        }
    }

    internal const val SAMSUNG = "android.ongoingActivityNoti."

    const val EXTRA_SHORT_CRITICAL_TEXT = "android.shortCriticalText"

    private val CATEGORIES = setOf("progress", "navigation", "transport", "workout", "status", "alarm", "service", "event")

    /** IR categories are the framework's `Notification.CATEGORY_*` strings. */
    internal fun category(raw: String): String = raw.takeIf { it in CATEGORIES } ?: NotificationCompat.CATEGORY_PROGRESS

    internal fun visibility(raw: String): Int = when (raw) {
        "private" -> NotificationCompat.VISIBILITY_PRIVATE
        "secret" -> NotificationCompat.VISIBILITY_SECRET
        else -> NotificationCompat.VISIBILITY_PUBLIC
    }

    /** App / framework drawable named like the symbol, else a public framework media icon, else 0. */
    internal fun actionIcon(c: Context, systemName: String): Int =
        systemIconResId(c, systemName).takeIf { it != 0 } ?: when (systemName.removeSuffix(".fill")) {
            "play" -> android.R.drawable.ic_media_play
            "pause" -> android.R.drawable.ic_media_pause
            "forward" -> android.R.drawable.ic_media_ff
            "backward" -> android.R.drawable.ic_media_rew
            "xmark" -> android.R.drawable.ic_menu_close_clear_cancel
            else -> 0
        }

    private fun openApp(c: Context, url: String?, requestCode: Int): PendingIntent? {
        val launch = c.packageManager.getLaunchIntentForPackage(c.packageName)
            // No launcher activity: a deep link still opens through the app's VIEW filter.
            ?: url?.let { Intent(Intent.ACTION_VIEW).setPackage(c.packageName).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
            ?: return null
        if (url != null) {
            launch.action = Intent.ACTION_VIEW
            launch.data = Uri.parse(url)
        }
        return PendingIntent.getActivity(c, requestCode, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
    }

    fun isActive(c: Context, id: String): Boolean = prefs(c).contains("active:$id")

    fun end(c: Context, id: String): Boolean {
        if (!isActive(c, id)) return false
        NotificationManagerCompat.from(c).cancel(notificationId(c, id))
        prefs(c).edit().remove("active:$id").remove("ts:$id").apply()
        return true
    }

    /** Ids still shown (the user may have dismissed some on Android 14+). */
    fun list(c: Context): List<String> {
        val ids = prefs(c).all.keys.filter { it.startsWith("active:") }.map { it.removePrefix("active:") }
        val nm = c.getSystemService(NotificationManager::class.java) ?: return ids.sorted()
        val shown = nm.activeNotifications.map { it.id }.toSet()
        val (alive, gone) = ids.partition { notificationId(c, it) in shown }
        if (gone.isNotEmpty()) prefs(c).edit().apply { gone.forEach { remove("active:$it"); remove("ts:$it") } }.apply()
        return alive.sorted()
    }
}

/** Action button tap: queue it for the app (delivered as `widget-action`). */
class ActivityActionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val id = intent.getStringExtra(ActivityNotifications.EXTRA_ID) ?: return
        val action = intent.getStringExtra(ActivityNotifications.EXTRA_ACTION) ?: return
        val group = intent.getStringExtra(ActivityNotifications.EXTRA_GROUP) ?: return
        WidgetStoreKeys.enqueueAction(context, WidgetSanitizer.sanitizeGroup(group, context.packageName), action, null, "activity.$id")
        WidgetBridgePlugin.pluginInstance?.takeIf { it.isAlive() }?.notifyPendingActions(group)
    }
}