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:
parent
5785a77211
commit
3ad214683a
17 changed files with 678 additions and 3 deletions
14
AGENTS.md
14
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
|
`/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
|
watch), `GET /pending?token&since` → `{ seq, at, changed }` (a content-free
|
||||||
cursor), and `/native/poll/unregister`; `pushTo()` calls `recordActivity()`.
|
cursor), and `/native/poll/unregister`; `pushTo()` calls `recordActivity()`.
|
||||||
**STILL TODO:** the native `skatespots-background` Capacitor plugin (Kotlin
|
**Plugin WRITTEN (2026-10-01):** `packages/skatespots-background/` — a local
|
||||||
WorkManager + foreground service + local notifications + battery-exemption
|
Capacitor Android plugin (Kotlin: `SkateSpotsBackgroundPlugin`, `PollWorker`
|
||||||
prompt) — unbuilt, needs a device to verify.
|
[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
|
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
|
||||||
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
||||||
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export default tseslint.config(
|
||||||
"ai-proxy",
|
"ai-proxy",
|
||||||
// Standalone Bun push service — own tsconfig (`typecheck:push`).
|
// Standalone Bun push service — own tsconfig (`typecheck:push`).
|
||||||
"push-service",
|
"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
|
// Static utility HTML (icon generators, sticker sheets, QR pages) that
|
||||||
// are intentionally minimal and don't need app-level meta/OG tags.
|
// are intentionally minimal and don't need app-level meta/OG tags.
|
||||||
"public/**/*.html",
|
"public/**/*.html",
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -99,6 +99,7 @@
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
|
"skatespots-background": "file:packages/skatespots-background",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.185.1",
|
"three": "^0.185.1",
|
||||||
|
|
@ -11295,6 +11296,10 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/skatespots-background": {
|
||||||
|
"resolved": "packages/skatespots-background",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||||
|
|
@ -13618,6 +13623,12 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/skatespots-background": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/core": "^8.4.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
|
"skatespots-background": "file:packages/skatespots-background",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.185.1",
|
"three": "^0.185.1",
|
||||||
|
|
|
||||||
49
packages/skatespots-background/README.md
Normal file
49
packages/skatespots-background/README.md
Normal 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`.
|
||||||
51
packages/skatespots-background/android/build.gradle
Normal file
51
packages/skatespots-background/android/build.gradle
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/skatespots-background/package.json
Normal file
18
packages/skatespots-background/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/skatespots-background/src/index.ts
Normal file
55
packages/skatespots-background/src/index.ts
Normal 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');
|
||||||
|
|
@ -4,6 +4,8 @@ import { Switch } from '@/components/ui/switch';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
||||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
|
import { useBackgroundDelivery } from '@/hooks/useBackgroundDelivery';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
||||||
import {
|
import {
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
|
|
@ -25,6 +27,8 @@ export function NotificationSettingsCard() {
|
||||||
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
|
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const push = usePushNotifications();
|
const push = usePushNotifications();
|
||||||
|
const background = useBackgroundDelivery();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -173,6 +177,49 @@ export function NotificationSettingsCard() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Per-category toggles */}
|
||||||
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
<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">
|
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
|
||||||
|
|
|
||||||
72
src/hooks/useBackgroundDelivery.ts
Normal file
72
src/hooks/useBackgroundDelivery.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue