From 3ad214683add0a45f4e2b7f27d91459598affe17 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 19:51:56 +0700 Subject: [PATCH] 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. --- AGENTS.md | 14 ++- eslint.config.js | 2 + package-lock.json | 11 ++ package.json | 1 + packages/skatespots-background/README.md | 49 +++++++++ .../android/build.gradle | 51 +++++++++ .../android/src/main/AndroidManifest.xml | 17 +++ .../org/skatespots/background/Notifier.kt | 69 ++++++++++++ .../org/skatespots/background/PollPrefs.kt | 40 +++++++ .../org/skatespots/background/PollService.kt | 73 +++++++++++++ .../org/skatespots/background/PollWorker.kt | 19 ++++ .../java/org/skatespots/background/Poller.kt | 42 ++++++++ .../background/SkateSpotsBackgroundPlugin.kt | 101 ++++++++++++++++++ packages/skatespots-background/package.json | 18 ++++ packages/skatespots-background/src/index.ts | 55 ++++++++++ src/components/NotificationSettingsCard.tsx | 47 ++++++++ src/hooks/useBackgroundDelivery.ts | 72 +++++++++++++ 17 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 packages/skatespots-background/README.md create mode 100644 packages/skatespots-background/android/build.gradle create mode 100644 packages/skatespots-background/android/src/main/AndroidManifest.xml create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/Notifier.kt create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/PollPrefs.kt create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/PollService.kt create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/PollWorker.kt create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/Poller.kt create mode 100644 packages/skatespots-background/android/src/main/java/org/skatespots/background/SkateSpotsBackgroundPlugin.kt create mode 100644 packages/skatespots-background/package.json create mode 100644 packages/skatespots-background/src/index.ts create mode 100644 src/hooks/useBackgroundDelivery.ts 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 }; +}