feat(android): native background poller plugin (no Google) - Cordn pattern

packages/skatespots-background: a local Capacitor Android plugin that polls our
own push-service /pending and raises local notifications. Modes: off / standard
(WorkManager ~15 min) / fast (foreground service ~5 min). Kotlin +
AndroidManifest (POST_NOTIFICATIONS, FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK,
battery-exemption) + Gradle module. Client: useBackgroundDelivery (registers a
poll token via NIP-98, seeds the plugin) + a Background delivery (Android)
control in NotificationSettingsCard. The app registers by NAME, not by importing
the package, so the web bundle is unaffected. UNVERIFIED - needs cap sync + a
device.
This commit is contained in:
SkateSpots 2026-10-01 19:51:56 +07:00
parent 5785a77211
commit 3ad214683a
17 changed files with 678 additions and 3 deletions

View file

@ -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.)

View file

@ -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",

11
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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`.

View file

@ -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"
}

View file

@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14+ requires a typed foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application>
<service
android:name=".PollService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

@ -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)
}
}

View file

@ -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() }
}

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -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<PollWorker>(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))
}
}

View file

@ -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"
}
}

View file

@ -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<void>;
/** Store the poll target (base URL + token + cursor). Call on enable/login. */
seed(options: SeedOptions): Promise<void>;
/** Post a local notification (used by the live in-app path for consistency). */
postNotification(options?: PostNotificationOptions): Promise<void>;
/** Cancel all message notifications. */
clearNotifications(): Promise<void>;
/** 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<void>;
}
export const SkateSpotsBackground = registerPlugin<SkateSpotsBackgroundPlugin>('SkateSpotsBackground');

View file

@ -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<OsPermission | 'loading'>('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() {
</p>
</div>
{/* Background delivery (Android only — no Google services). */}
{background.supported && (
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="px-4 py-3.5">
<p className="text-sm font-bold text-white">Background delivery (Android)</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Check for new messages, offers and trades while the app is closed — no Google services.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant={background.mode === 'off' ? 'default' : 'outline'} onClick={() => void background.disable()}>
Off
</Button>
<Button
size="sm"
variant={background.mode === 'standard' ? 'default' : 'outline'}
onClick={() => {
void background.enable('standard').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
});
}}
>
Standard (~15 min)
</Button>
<Button
size="sm"
variant={background.mode === 'fast' ? 'default' : 'outline'}
onClick={() => {
void background.enable('fast').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
});
}}
>
Fast (~5 min)
</Button>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">
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.
</p>
</div>
</div>
)}
{/* Per-category toggles */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">

View file

@ -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<void>;
seed(options: { baseUrl: string; token: string; since: number }): Promise<void>;
clearNotifications(): Promise<void>;
}
const SkateSpotsBackground = registerPlugin<SkateSpotsBackgroundPlugin>('SkateSpotsBackground');
export function useBackgroundDelivery() {
const { user } = useCurrentUser();
const supported = isNativePlatform() && getPlatform() === 'android';
const [mode, setMode] = useState<DeliveryMode>('off');
const enable = useCallback(
async (nextMode: Exclude<DeliveryMode, 'off'>, 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 };
}