mobiler 0.45.0

Build mobile apps in Rust — one core, native UI on Android, iOS, and the web (CLI)
package {{PACKAGE}}

import {{PACKAGE_SHARED_TYPES}}.PluginResponse

import android.app.Application
import android.content.Context
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.json.JSONArray
import org.json.JSONObject

// background-fetch (free, bundled, EXPERIMENTAL). WorkManager wakes the app on its own schedule to run
// BackgroundFetchWorker, which posts a local notification AND feeds BackgroundFetchBus (persisted
// buffer) → the cx.subscribe stream on next foreground. The Rust core never runs in the background.
//   cx.plugin("background-fetch","schedule",{"id":"refresh","min_interval_seconds":3600,"notify_title":..,"notify_body":..})
//   cx.subscribe(key,"background-fetch","events","",on) → {"type":"fetch","id":"refresh"}
//
// WorkManager enforces a 15-minute minimum interval; `min_interval_seconds` is clamped up to that floor.
class BackgroundFetchPlugin(private val application: Application) : MobilerPlugin {
    override suspend fun handle(op: String, input: String): PluginResponse = when (op) {
        "schedule" -> schedule(input)
        "cancel" -> cancel(input)
        else -> PluginResponse(false, "unknown op '$op'")
    }

    // Streaming entrypoint (cx.subscribe): emit each fetch event until the collecting Job is cancelled.
    override fun subscribe(op: String, input: String): Flow<PluginResponse> = callbackFlow {
        val sink: (String) -> Unit = { trySend(PluginResponse(true, it)) }
        BackgroundFetchBus.attach(application, sink)
        awaitClose { BackgroundFetchBus.detach(sink) }
    }

    private fun schedule(input: String): PluginResponse {
        val obj = runCatching { JSONObject(input) }.getOrNull()
            ?: return PluginResponse(false, "invalid input JSON")
        val id = obj.optString("id", "refresh")
        // WorkManager's minimum periodic interval is 15 min; treat min_interval_seconds as a floor.
        val seconds = obj.optLong("min_interval_seconds", 3600).coerceAtLeast(15 * 60)
        val data = Data.Builder()
            .putString("id", id)
            .putString("title", obj.optString("notify_title"))
            .putString("body", obj.optString("notify_body"))
            .build()
        val request = PeriodicWorkRequestBuilder<BackgroundFetchWorker>(seconds, TimeUnit.SECONDS)
            .setInputData(data)
            .build()
        WorkManager.getInstance(application)
            .enqueueUniquePeriodicWork(id, ExistingPeriodicWorkPolicy.UPDATE, request)
        return PluginResponse(true, "")
    }

    private fun cancel(input: String): PluginResponse {
        val id = runCatching { JSONObject(input).optString("id", "refresh") }.getOrDefault("refresh")
        WorkManager.getInstance(application).cancelUniqueWork(id)
        return PluginResponse(true, "")
    }
}

// In-process bus between BackgroundFetchWorker (which may run with no UI alive) and the cx.subscribe
// stream. Persists pending events in SharedPreferences so a wake that ran in a since-killed process is
// still delivered on the next launch (flushed on attach). Mirrors PushBus / GeofenceBus.
object BackgroundFetchBus {
    private const val PREFS = "mobiler.bgfetch"
    private const val BUFFER = "buffer"
    private var sink: ((String) -> Unit)? = null

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

    @Synchronized fun attach(ctx: Context, s: (String) -> Unit) {
        sink = s
        val arr = JSONArray(prefs(ctx).getString(BUFFER, "[]"))
        for (i in 0 until arr.length()) s(arr.getString(i))
        prefs(ctx).edit().remove(BUFFER).apply()
    }

    @Synchronized fun detach(s: (String) -> Unit) {
        if (sink === s) sink = null
    }

    @Synchronized fun emit(ctx: Context, payload: String) {
        val s = sink
        if (s != null) {
            s(payload)
        } else {
            val arr = JSONArray(prefs(ctx).getString(BUFFER, "[]"))
            arr.put(payload)
            prefs(ctx).edit().putString(BUFFER, arr.toString()).apply()
        }
    }
}