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.Activity
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.ListView
import androidx.core.content.FileProvider
import androidx.glance.appwidget.GlanceAppWidgetManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONArray
import java.io.File

/**
 * Widget configuration (`android:configure`, `widgetFeatures="reconfigurable|configuration_optional"`).
 * A trampoline: with no `instances:{widgetId}` choices (`setWidgetInstances`) it finishes at
 * once; otherwise the user picks one and that becomes the placement's instance id (keys
 * `config:{id}#{instance}`, `data:…`, `state:…`, `{{instance}}`).
 */
class WidgetConfigActivity : Activity() {
    private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appWidgetId = intent?.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
            ?: AppWidgetManager.INVALID_APPWIDGET_ID
        // Back / dismiss = cancel (the launcher drops a new placement).
        setResult(RESULT_CANCELED, resultIntent())
        if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return finish()
        val options = options(this, appWidgetId)
        if (options.isEmpty()) return finishWidgetConfiguration(null)
        setTitle(R.string.tauri_widget_configure_title)
        val list = ListView(this)
        list.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, options.map { it.second })
        list.setOnItemClickListener { _, _, pos, _ -> finishWidgetConfiguration(options[pos].first) }
        setContentView(list)
    }

    private fun resultIntent() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)

    /** Store the choice (null keeps the default), redraw the placement, report RESULT_OK. */
    private fun finishWidgetConfiguration(instance: String?) {
        if (instance != null) WidgetStoreKeys.setPlacementInstance(this, appWidgetId, instance)
        val app = applicationContext
        val id = appWidgetId
        CoroutineScope(Dispatchers.Default).launch {
            runCatching { TauriGlanceWidget().update(app, GlanceAppWidgetManager(app).getGlanceIdBy(id)) }
        }
        setResult(RESULT_OK, resultIntent())
        finish()
    }

    companion object {
        /** `(id, title)` choices for the widget this placement shows. */
        fun options(context: Context, appWidgetId: Int): List<Pair<String, String>> {
            val group = WidgetStoreKeys.resolveGroup(context, appWidgetId)
            val widgetId = WidgetStoreKeys.resolveWidgetId(context, appWidgetId)
            val raw = context.getSharedPreferences(group, Context.MODE_PRIVATE)
                .getString(WidgetSanitizer.sanitizeKey(WidgetStoreKeys.INSTANCES_PREFIX + widgetId), null)
            return parseOptions(raw)
        }

        fun parseOptions(raw: String?): List<Pair<String, String>> {
            val arr = runCatching { JSONArray(raw ?: return emptyList()) }.getOrNull() ?: return emptyList()
            return (0 until arr.length()).mapNotNull { i ->
                val o = arr.optJSONObject(i) ?: return@mapNotNull null
                val id = o.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
                id to o.optString("title").ifEmpty { id }
            }
        }
    }
}

/** Hands cached widget images to the launcher by content URI (no RemoteViews bitmap budget). */
class TauriWidgetFileProvider : FileProvider()

/**
 * Content URI for a file in the plugin image cache, readable by the home-screen app(s);
 * null when the file is elsewhere or the provider is missing (callers fall back to a bitmap).
 */
internal fun cachedImageUri(context: Context, path: String): Uri? = runCatching {
    val file = File(path)
    val dir = File(context.cacheDir, "tauri_widget_images")
    if (!file.isFile || file.canonicalFile.parentFile != dir.canonicalFile) return null
    val authority = context.packageManager
        .getProviderInfo(ComponentName(context, TauriWidgetFileProvider::class.java), 0).authority
    val uri = FileProvider.getUriForFile(context, authority, file)
    val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
    @Suppress("DEPRECATION")
    context.packageManager.queryIntentActivities(home, 0).map { it.activityInfo.packageName }.distinct().forEach {
        context.grantUriPermission(it, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    uri
}.getOrNull()