diff --git a/AGENTS.md b/AGENTS.md
index 0cbc58a..3f173fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -802,9 +802,17 @@ session with **no other memory of this project**. So:
`/native/poll/register` (NIP-98 → poll token; the pubkey is added to the relay
watch), `GET /pending?token&since` → `{ seq, at, changed }` (a content-free
cursor), and `/native/poll/unregister`; `pushTo()` calls `recordActivity()`.
- **STILL TODO:** the native `skatespots-background` Capacitor plugin (Kotlin
- WorkManager + foreground service + local notifications + battery-exemption
- prompt) — unbuilt, needs a device to verify.
+ **Plugin WRITTEN (2026-10-01):** `packages/skatespots-background/` — a local
+ Capacitor Android plugin (Kotlin: `SkateSpotsBackgroundPlugin`, `PollWorker`
+ [WorkManager], `PollService` [foreground], `Poller`, `Notifier`, `PollPrefs`)
+ plus `src/hooks/useBackgroundDelivery.ts` and a **Background delivery
+ (Android)** control in `NotificationSettingsCard` (Off / Standard / Fast).
+ The app registers it **by NAME** via `@capacitor/core` — it does **not**
+ import the package (no web-bundle dep); only the Android shell does, via the
+ `file:` dep + `cap sync`. ⚠️ **UNVERIFIED — needs `cap sync` + a device.**
+ `android/` is regenerated, so the `file:` dep must stay in `package.json` for
+ `cap sync` to add the module; `build.gradle`/manifest may need tuning per the
+ app's Capacitor version.
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
defer it); `fast` needs a sticky notification. Never the only path. (Approach
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
diff --git a/eslint.config.js b/eslint.config.js
index 440e04d..0f6c40d 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -26,6 +26,8 @@ export default tseslint.config(
"ai-proxy",
// Standalone Bun push service — own tsconfig (`typecheck:push`).
"push-service",
+ // Local Capacitor plugins (Kotlin + a TS bridge built only for the shell).
+ "packages",
// Static utility HTML (icon generators, sticker sheets, QR pages) that
// are intentionally minimal and don't need app-level meta/OG tags.
"public/**/*.html",
diff --git a/package-lock.json b/package-lock.json
index d93029d..620a480 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -99,6 +99,7 @@
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
+ "skatespots-background": "file:packages/skatespots-background",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.185.1",
@@ -11295,6 +11296,10 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/skatespots-background": {
+ "resolved": "packages/skatespots-background",
+ "link": true
+ },
"node_modules/slice-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
@@ -13618,6 +13623,12 @@
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
+ },
+ "packages/skatespots-background": {
+ "version": "0.0.1",
+ "dependencies": {
+ "@capacitor/core": "^8.4.2"
+ }
}
}
}
diff --git a/package.json b/package.json
index 210925c..5f4eff4 100644
--- a/package.json
+++ b/package.json
@@ -117,6 +117,7 @@
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
+ "skatespots-background": "file:packages/skatespots-background",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.185.1",
diff --git a/packages/skatespots-background/README.md b/packages/skatespots-background/README.md
new file mode 100644
index 0000000..c4f9614
--- /dev/null
+++ b/packages/skatespots-background/README.md
@@ -0,0 +1,49 @@
+# skatespots-background
+
+A **local Capacitor Android plugin** that gives the APK closed-app notifications
+**without Google services** — the [Cordn](https://github.com/Cordn-msg/cordn-web)
+pattern, simplified (our payloads are already content-free, so there is **no Rust
+and no decryption** here).
+
+It polls our own `push-service` (`GET /pending?token&since` → `{seq, at, changed}`)
+and, when the cursor advances, raises a **local notification**.
+
+## Modes
+| Mode | Mechanism | Cadence | Notes |
+| --- | --- | --- | --- |
+| `off` | — | — | default |
+| `standard` | **WorkManager** | ~15 min (OS minimum) | best-effort — Doze/OEM battery managers may defer it |
+| `fast` | **foreground service** | ~5 min | near-live; shows a **sticky notification** + battery |
+
+## Files
+- `src/index.ts` — TS↔Kotlin bridge (the app registers the plugin **by name**).
+- `android/src/main/AndroidManifest.xml` — permissions (`POST_NOTIFICATIONS`,
+ `FOREGROUND_SERVICE[_DATA_SYNC]`, `WAKE_LOCK`, `RECEIVE_BOOT_COMPLETED`,
+ `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) + the service.
+- `android/src/main/java/org/skatespots/background/`:
+ - `SkateSpotsBackgroundPlugin.kt` — the `@CapacitorPlugin` (`configure`, `seed`,
+ `postNotification`, `clearNotifications`, `consumePendingUrl`,
+ `isBatteryExempted`, `requestBatteryExemption`).
+ - `PollWorker.kt` (WorkManager), `PollService.kt` (foreground), `Poller.kt`,
+ `Notifier.kt`, `PollPrefs.kt`.
+- `android/build.gradle` — the plugin Gradle module.
+
+## Wiring
+1. The root `package.json` depends on it: `"skatespots-background": "file:packages/skatespots-background"`.
+2. `npm run android:init` / `android:sync` (which run `npx cap sync android`) pick
+ it up and add the Android module.
+3. The client enables it from **Settings → Notifications → Background delivery
+ (Android)** via `src/hooks/useBackgroundDelivery.ts`.
+
+## ⚠️ Caveats
+- **Android-only** and **cannot be verified in CI** — needs a device.
+- `standard` is **not** a guarantee (Doze/OEM). `fast` needs the sticky
+ notification and the user must start it while the app is foreground.
+- `build.gradle` mirrors the Capacitor plugin template; align its Gradle/Kotlin
+ versions with the app's Capacitor major if a build complains.
+- The small icon uses `applicationInfo.icon`; swap for a dedicated monochrome
+ status icon if the app's launcher icon renders poorly in the shade.
+
+## Server contract
+`push-service` (see its README): `POST /native/poll/register` (NIP-98 proof →
+poll token), `GET /pending?token&since`, `POST /native/poll/unregister`.
diff --git a/packages/skatespots-background/android/build.gradle b/packages/skatespots-background/android/build.gradle
new file mode 100644
index 0000000..2eb298f
--- /dev/null
+++ b/packages/skatespots-background/android/build.gradle
@@ -0,0 +1,51 @@
+// SkateSpots background poller — a local Capacitor Android plugin.
+// Mirrors the official Capacitor plugin template; tune versions to match the
+// app's Capacitor major if a build complains.
+ext {
+ androidxWorkVersion = project.hasProperty('androidxWorkVersion') ? rootProject.ext.androidxWorkVersion : '2.9.1'
+ androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.13.1'
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.2.1'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ namespace "org.skatespots.background"
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
+
+ defaultConfig {
+ minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
+ targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+}
+
+dependencies {
+ implementation project(':capacitor-android')
+ implementation "androidx.work:work-runtime-ktx:$androidxWorkVersion"
+ implementation "androidx.core:core-ktx:$androidxCoreVersion"
+}
diff --git a/packages/skatespots-background/android/src/main/AndroidManifest.xml b/packages/skatespots-background/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..746b988
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/Notifier.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/Notifier.kt
new file mode 100644
index 0000000..547f8f3
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/Notifier.kt
@@ -0,0 +1,69 @@
+package org.skatespots.background
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+
+/** Local notifications — no Google. */
+object Notifier {
+ const val CHANNEL_ID = "skatespots_activity"
+ const val MESSAGE_ID = 4201
+ const val SERVICE_ID = 4200
+
+ fun ensureChannel(ctx: Context) {
+ val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "SkateSpots activity", NotificationManager.IMPORTANCE_DEFAULT).apply {
+ description = "New messages, offers and trade updates"
+ },
+ )
+ }
+ }
+
+ fun show(ctx: Context, title: String, body: String, url: String) {
+ ensureChannel(ctx)
+ // Remember where a tap should land (consumed by the WebView on resume).
+ PollPrefs.setPendingUrl(ctx, url)
+
+ val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ } ?: return
+ val pending = PendingIntent.getActivity(
+ ctx,
+ 0,
+ launch,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notif = NotificationCompat.Builder(ctx, CHANNEL_ID)
+ .setSmallIcon(ctx.applicationInfo.icon)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setContentIntent(pending)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+
+ // POST_NOTIFICATIONS is required on Android 13+; the app asks for it.
+ if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ try {
+ NotificationManagerCompat.from(ctx).notify(MESSAGE_ID, notif)
+ } catch (_: SecurityException) {
+ // permission revoked mid-flight — ignore
+ }
+ }
+
+ fun clear(ctx: Context) {
+ NotificationManagerCompat.from(ctx).cancel(MESSAGE_ID)
+ }
+}
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollPrefs.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollPrefs.kt
new file mode 100644
index 0000000..210bdb5
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollPrefs.kt
@@ -0,0 +1,40 @@
+package org.skatespots.background
+
+import android.content.Context
+import androidx.core.content.edit
+
+/** Tiny SharedPreferences store for the poll target + cursor + tap URL. */
+object PollPrefs {
+ private const val NAME = "skatespots_background"
+
+ private fun prefs(ctx: Context) = ctx.getSharedPreferences(NAME, Context.MODE_PRIVATE)
+
+ fun baseUrl(ctx: Context): String? = prefs(ctx).getString("baseUrl", null)
+ fun token(ctx: Context): String? = prefs(ctx).getString("token", null)
+ fun cursor(ctx: Context): Long = prefs(ctx).getLong("cursor", 0L)
+ fun intervalMinutes(ctx: Context): Int = prefs(ctx).getInt("interval", 5)
+ fun mode(ctx: Context): String = prefs(ctx).getString("mode", "off") ?: "off"
+
+ fun seed(ctx: Context, baseUrl: String, token: String, since: Long) = prefs(ctx).edit {
+ putString("baseUrl", baseUrl)
+ putString("token", token)
+ putLong("cursor", since)
+ }
+
+ fun setCursor(ctx: Context, value: Long) = prefs(ctx).edit { putLong("cursor", value) }
+
+ fun configure(ctx: Context, mode: String, interval: Int) = prefs(ctx).edit {
+ putString("mode", mode)
+ putInt("interval", interval)
+ }
+
+ /** A tapped notification's in-app path, consumed once by the WebView. */
+ fun setPendingUrl(ctx: Context, url: String) = prefs(ctx).edit { putString("pendingUrl", url) }
+ fun consumePendingUrl(ctx: Context): String? {
+ val url = prefs(ctx).getString("pendingUrl", null)
+ if (url != null) prefs(ctx).edit { remove("pendingUrl") }
+ return url
+ }
+
+ fun clear(ctx: Context) = prefs(ctx).edit { clear() }
+}
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollService.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollService.kt
new file mode 100644
index 0000000..d649a7e
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollService.kt
@@ -0,0 +1,73 @@
+package org.skatespots.background
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/**
+ * `fast` mode — a foreground service polling every N minutes. Near-live, but it
+ * shows a **sticky notification** and costs battery; the user opts in.
+ * Started only while the app is in the foreground (Android restriction).
+ */
+class PollService : Service() {
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var loop: Job? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ Notifier.ensureChannel(this)
+ val notif = NotificationCompat.Builder(this, Notifier.CHANNEL_ID)
+ .setSmallIcon(applicationInfo.icon)
+ .setContentTitle("SkateSpots")
+ .setContentText("Keeping you up to date")
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ val type =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
+ ServiceCompat.startForeground(this, Notifier.SERVICE_ID, notif, type)
+ startLoop()
+ return START_STICKY
+ }
+
+ private fun startLoop() {
+ if (loop?.isActive == true) return
+ loop = scope.launch {
+ while (isActive) {
+ val res = Poller.poll(applicationContext)
+ if (res?.changed == true) {
+ Notifier.show(applicationContext, "SkateSpots", "You have new activity", "/#/trades")
+ }
+ val minutes = PollPrefs.intervalMinutes(applicationContext).coerceAtLeast(1)
+ delay(minutes * 60_000L)
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ loop?.cancel()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ companion object {
+ const val ACTION_STOP = "org.skatespots.background.STOP"
+ }
+}
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollWorker.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollWorker.kt
new file mode 100644
index 0000000..cb9a3b3
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/PollWorker.kt
@@ -0,0 +1,19 @@
+package org.skatespots.background
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+
+/**
+ * `standard` mode — WorkManager, ~15 min (the OS minimum). Best-effort: Doze /
+ * OEM battery managers may defer it, so treat it as "eventually", not "instant".
+ */
+class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
+ override suspend fun doWork(): Result {
+ val res = Poller.poll(applicationContext)
+ if (res?.changed == true) {
+ Notifier.show(applicationContext, "SkateSpots", "You have new activity", "/#/trades")
+ }
+ return Result.success()
+ }
+}
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/Poller.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/Poller.kt
new file mode 100644
index 0000000..37218fa
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/Poller.kt
@@ -0,0 +1,42 @@
+package org.skatespots.background
+
+import android.content.Context
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * One poll of `GET {base}/pending?token&since` → `{ seq, at, changed }`.
+ * Content-free: the server never tells us *what* arrived.
+ */
+object Poller {
+ data class Result(val changed: Boolean, val seq: Long)
+
+ fun poll(ctx: Context): Result? {
+ val base = PollPrefs.baseUrl(ctx) ?: return null
+ val token = PollPrefs.token(ctx) ?: return null
+ val since = PollPrefs.cursor(ctx)
+ val conn = try {
+ (URL("$base/pending?token=$token&since=$since").openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ }
+ } catch (e: Exception) {
+ return null
+ }
+ return try {
+ if (conn.responseCode != 200) return null
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ val json = JSONObject(body)
+ val seq = json.optLong("seq", since)
+ val changed = json.optBoolean("changed", seq > since)
+ if (changed) PollPrefs.setCursor(ctx, seq)
+ Result(changed, seq)
+ } catch (e: Exception) {
+ null
+ } finally {
+ conn.disconnect()
+ }
+ }
+}
diff --git a/packages/skatespots-background/android/src/main/java/org/skatespots/background/SkateSpotsBackgroundPlugin.kt b/packages/skatespots-background/android/src/main/java/org/skatespots/background/SkateSpotsBackgroundPlugin.kt
new file mode 100644
index 0000000..6f42fa4
--- /dev/null
+++ b/packages/skatespots-background/android/src/main/java/org/skatespots/background/SkateSpotsBackgroundPlugin.kt
@@ -0,0 +1,101 @@
+package org.skatespots.background
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.core.content.ContextCompat
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+import java.util.concurrent.TimeUnit
+
+@CapacitorPlugin(name = "SkateSpotsBackground")
+class SkateSpotsBackgroundPlugin : Plugin() {
+
+ @PluginMethod
+ fun configure(call: PluginCall) {
+ val mode = call.getString("mode") ?: "off"
+ val interval = call.getInt("intervalMinutes") ?: 5
+ PollPrefs.configure(context, mode, interval)
+ when (mode) {
+ "standard" -> startStandard(context)
+ "fast" -> startFast(context)
+ else -> stopAll(context)
+ }
+ call.resolve()
+ }
+
+ @PluginMethod
+ fun seed(call: PluginCall) {
+ val baseUrl = call.getString("baseUrl") ?: run { call.reject("baseUrl required"); return }
+ val token = call.getString("token") ?: run { call.reject("token required"); return }
+ val since = call.getLong("since") ?: 0L
+ PollPrefs.seed(context, baseUrl, token, since)
+ call.resolve()
+ }
+
+ @PluginMethod
+ fun postNotification(call: PluginCall) {
+ Notifier.show(
+ context,
+ call.getString("title") ?: "SkateSpots",
+ call.getString("body") ?: "You have new activity",
+ call.getString("url") ?: "/#/trades",
+ )
+ call.resolve()
+ }
+
+ @PluginMethod
+ fun clearNotifications(call: PluginCall) {
+ Notifier.clear(context)
+ call.resolve()
+ }
+
+ /** A tapped notification's in-app path (HashRouter), consumed once. */
+ @PluginMethod
+ fun consumePendingUrl(call: PluginCall) {
+ call.resolve(JSObject().put("url", PollPrefs.consumePendingUrl(context)))
+ }
+
+ @PluginMethod
+ fun isBatteryExempted(call: PluginCall) {
+ val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ call.resolve(JSObject().put("exempted", pm.isIgnoringBatteryOptimizations(context.packageName)))
+ }
+
+ @PluginMethod
+ fun requestBatteryExemption(call: PluginCall) {
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${context.packageName}")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ call.resolve()
+ }
+
+ /** `standard` = WorkManager ~15 min (best-effort). */
+ private fun startStandard(ctx: Context) {
+ val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build()
+ WorkManager.getInstance(ctx)
+ .enqueueUniquePeriodicWork("skatespots-poll", ExistingPeriodicWorkPolicy.UPDATE, request)
+ ctx.stopService(Intent(ctx, PollService::class.java))
+ }
+
+ /** `fast` = foreground service (sticky notification). */
+ private fun startFast(ctx: Context) {
+ WorkManager.getInstance(ctx).cancelUniqueWork("skatespots-poll")
+ ContextCompat.startForegroundService(ctx, Intent(ctx, PollService::class.java))
+ }
+
+ private fun stopAll(ctx: Context) {
+ WorkManager.getInstance(ctx).cancelUniqueWork("skatespots-poll")
+ ctx.stopService(Intent(ctx, PollService::class.java))
+ }
+}
diff --git a/packages/skatespots-background/package.json b/packages/skatespots-background/package.json
new file mode 100644
index 0000000..a253c6b
--- /dev/null
+++ b/packages/skatespots-background/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "skatespots-background",
+ "version": "0.0.1",
+ "private": true,
+ "description": "SkateSpots native background notification poller (WorkManager + foreground service). No Google services.",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "capacitor": {
+ "android": {
+ "src": "android"
+ }
+ },
+ "files": ["src", "android"],
+ "dependencies": {
+ "@capacitor/core": "^8.4.2"
+ }
+}
diff --git a/packages/skatespots-background/src/index.ts b/packages/skatespots-background/src/index.ts
new file mode 100644
index 0000000..9585411
--- /dev/null
+++ b/packages/skatespots-background/src/index.ts
@@ -0,0 +1,55 @@
+/**
+ * SkateSpots background delivery — TS↔Kotlin bridge.
+ *
+ * A native poller that asks our OWN `push-service` "anything new since N?" and
+ * raises a **local notification** when it moves — the Cordn pattern, with **no
+ * Google**. `/pending` returns only a content-free cursor, so the worker never
+ * decrypts anything.
+ *
+ * Every method rejects on web; callers guard with `isNativePlatform()`.
+ */
+import { registerPlugin } from '@capacitor/core';
+
+/** `off` | `standard` (WorkManager ~15 min) | `fast` (foreground service ~5 min). */
+export type DeliveryMode = 'off' | 'standard' | 'fast';
+
+export interface ConfigureOptions {
+ mode: DeliveryMode;
+ /** Minutes between foreground-service polls. Only used by `fast`. Default 5. */
+ intervalMinutes?: number;
+}
+
+export interface SeedOptions {
+ /** Base URL of the push service, e.g. https://push.skate-spots.org */
+ baseUrl: string;
+ /** The poll token from `/native/poll/register`. */
+ token: string;
+ /** The cursor we already know — the worker only notifies when it advances. */
+ since: number;
+}
+
+export interface PostNotificationOptions {
+ title?: string;
+ body?: string;
+ /** In-app path to open on tap (HashRouter), e.g. /#/trades */
+ url?: string;
+}
+
+export interface SkateSpotsBackgroundPlugin {
+ /** Set the delivery mode; starts/stops the foreground service accordingly. */
+ configure(options: ConfigureOptions): Promise;
+ /** Store the poll target (base URL + token + cursor). Call on enable/login. */
+ seed(options: SeedOptions): Promise;
+ /** Post a local notification (used by the live in-app path for consistency). */
+ postNotification(options?: PostNotificationOptions): Promise;
+ /** Cancel all message notifications. */
+ clearNotifications(): Promise;
+ /** A tapped notification's in-app path (HashRouter), consumed once. */
+ consumePendingUrl(): Promise<{ url: string | null }>;
+ /** Whether the app is exempt from Doze/battery optimization. */
+ isBatteryExempted(): Promise<{ exempted: boolean }>;
+ /** Open the system "disable battery optimization" prompt. */
+ requestBatteryExemption(): Promise;
+}
+
+export const SkateSpotsBackground = registerPlugin('SkateSpotsBackground');
diff --git a/src/components/NotificationSettingsCard.tsx b/src/components/NotificationSettingsCard.tsx
index 902c6f5..7212453 100644
--- a/src/components/NotificationSettingsCard.tsx
+++ b/src/components/NotificationSettingsCard.tsx
@@ -4,6 +4,8 @@ import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { usePushNotifications } from '@/hooks/usePushNotifications';
+import { useBackgroundDelivery } from '@/hooks/useBackgroundDelivery';
+import { useToast } from '@/hooks/useToast';
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
import {
getNotificationPermission,
@@ -25,6 +27,8 @@ export function NotificationSettingsCard() {
const [permission, setPermission] = useState('loading');
const [busy, setBusy] = useState(false);
const push = usePushNotifications();
+ const background = useBackgroundDelivery();
+ const { toast } = useToast();
useEffect(() => {
let cancelled = false;
@@ -173,6 +177,49 @@ export function NotificationSettingsCard() {
+ {/* Background delivery (Android only — no Google services). */}
+ {background.supported && (
+
+
+
Background delivery (Android)
+
+ Check for new messages, offers and trades while the app is closed — no Google services.
+
+
+
+
+
+
+
+ Standard is best-effort — Android may defer it under Doze. Fast shows a persistent
+ notification and uses more battery. Nothing is decrypted on our server.
+
+
+
+ )}
+
{/* Per-category toggles */}
diff --git a/src/hooks/useBackgroundDelivery.ts b/src/hooks/useBackgroundDelivery.ts
new file mode 100644
index 0000000..b5bf4e0
--- /dev/null
+++ b/src/hooks/useBackgroundDelivery.ts
@@ -0,0 +1,72 @@
+/**
+ * useBackgroundDelivery — Android-only closed-app notifications, **without
+ * Google**. Registers a poll token with our `push-service` and hands the native
+ * `skatespots-background` plugin the base URL + token so its worker can ask
+ * "anything new since N?" and raise a local notification.
+ *
+ * Two modes (chosen in Settings): `standard` = WorkManager ~15 min (best-effort),
+ * `fast` = a foreground service ~5 min (sticky notification).
+ */
+import { useCallback, useState } from 'react';
+import { registerPlugin } from '@capacitor/core';
+
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { isNativePlatform, getPlatform } from '@/lib/platform';
+import { pushServiceBase, requestPushChallenge } from '@/lib/pushService';
+
+type DeliveryMode = 'off' | 'standard' | 'fast';
+
+/**
+ * Registered by NAME (`@capacitor/core`), not imported from the package — the
+ * plugin's TS source lives in `packages/skatespots-background` and is only a
+ * build-time dependency of the Android shell (Capacitor finds it via package.json).
+ */
+interface SkateSpotsBackgroundPlugin {
+ configure(options: { mode: DeliveryMode; intervalMinutes?: number }): Promise;
+ seed(options: { baseUrl: string; token: string; since: number }): Promise;
+ clearNotifications(): Promise;
+}
+const SkateSpotsBackground = registerPlugin('SkateSpotsBackground');
+
+export function useBackgroundDelivery() {
+ const { user } = useCurrentUser();
+ const supported = isNativePlatform() && getPlatform() === 'android';
+ const [mode, setMode] = useState('off');
+
+ const enable = useCallback(
+ async (nextMode: Exclude, intervalMinutes = 5) => {
+ if (!supported) throw new Error('Background delivery is Android-only.');
+ if (!user?.pubkey || !user.signer) throw new Error('Log in first.');
+
+ const challenge = await requestPushChallenge(user.pubkey);
+ const proof = await user.signer.signEvent({
+ kind: 27235,
+ content: '',
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['challenge', challenge]],
+ });
+
+ const res = await fetch(`${pushServiceBase()}/native/poll/register`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ pubkey: user.pubkey, proof }),
+ });
+ if (!res.ok) throw new Error('Could not register for background delivery.');
+ const { token, seq } = (await res.json()) as { token: string; seq: number };
+
+ await SkateSpotsBackground.seed({ baseUrl: pushServiceBase(), token, since: seq ?? 0 });
+ await SkateSpotsBackground.configure({ mode: nextMode, intervalMinutes });
+ setMode(nextMode);
+ },
+ [supported, user],
+ );
+
+ const disable = useCallback(async () => {
+ if (!supported) return;
+ await SkateSpotsBackground.configure({ mode: 'off' });
+ await SkateSpotsBackground.clearNotifications();
+ setMode('off');
+ }, [supported]);
+
+ return { supported, mode, enable, disable };
+}