diff --git a/README.md b/README.md
index 2ec5a53..39f7ab9 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ bigbrainparking/
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
| Active / past sessions | ✅ wired | list views + tap for full receipt |
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
+| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
@@ -75,12 +76,35 @@ in `app/app.json` to point elsewhere.
## Notifications on GrapheneOS
-Session-expiry reminders are scheduled **entirely on-device** from each session's end time
-(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
-Services. They work fully offline. Configure the lead time (default 15 min) in
-**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
-fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
-*future* server-initiated messages; nothing time-based needs it.
+Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
+so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
+any *future* server-initiated messages; nothing time-based needs it.
+
+**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
+a free check-in — a persistent notification shows the time left and ticks down, with
+**End** and **Extend** buttons. It is held up by a real **foreground service**
+(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
+way ntfy's does: it survives the app being killed, can't be swiped away, and a
+`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
+session's life — it stops itself, removing the notification, on **End**, when the meter
+runs out, or whenever there's no session to show.
+
+The countdown itself costs no battery: the end time is handed to Android as a
+[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
+and the system redraws the ticking text with the app closed and no timer of its own.
+
+- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
+ ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
+ endpoint, so time you already bought keeps running at the meter either way.
+- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
+ zone. The active session is stored locally *with its zone*, so this works offline, in
+ Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
+ actually goes through.
+
+**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
+`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
+reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
+for the ongoing countdown.
## Distribution via Obtainium (self-hosted)
diff --git a/app/app.json b/app/app.json
index 9738ac2..3f64a24 100644
--- a/app/app.json
+++ b/app/app.json
@@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
- "version": "0.4.1",
+ "version": "0.5.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
- "versionCode": 19,
+ "versionCode": 20,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
diff --git a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml
index 60c738b..c28d9dd 100644
--- a/app/modules/bbp-notify/android/src/main/AndroidManifest.xml
+++ b/app/modules/bbp-notify/android/src/main/AndroidManifest.xml
@@ -1,9 +1,37 @@
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt
index 6647f2a..0071678 100644
--- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpActionReceiver.kt
@@ -3,24 +3,28 @@ package expo.modules.bbpnotify
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-import androidx.core.app.NotificationManagerCompat
/**
- * Handles the check-in notification's "End" / "Pay" buttons, even when the app
- * process is dead. It records the choice in SharedPreferences (read by JS via
- * `consumePendingAction` on next foreground) and, for "Pay", relaunches the app
- * so the check-in can hand off to the paid-session flow.
+ * Handles the notification's "End" / "Extend" buttons, even when the app process is
+ * dead. Each button records its choice in [BbpSessionStore]; JS picks it up via
+ * `consumePendingAction` the next time it runs and finishes the job on its side
+ * (clearing local state, cancelling the expiry reminder, opening the pay screen).
*/
class BbpActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE)
when (intent.action) {
- BbpNotifyModule.ACTION_END -> {
- prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply()
- NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID)
+ ACTION_END -> {
+ // Ending is immediate and local: drop the session and take the notification
+ // down with the service, without waiting for the app to be opened.
+ BbpSessionStore.setPending(context, "end")
+ BbpSessionStore.clear(context)
+ BbpSessionService.stop(context)
}
- BbpNotifyModule.ACTION_PAY -> {
- prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
+ ACTION_EXTEND -> {
+ // Extending does NOT end anything — the time already bought keeps running,
+ // and the purchase might be abandoned. Leave the session and its countdown
+ // alone; JS replaces them if and when more time is actually bought.
+ BbpSessionStore.setPending(context, "extend")
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(launch)
@@ -28,4 +32,9 @@ class BbpActionReceiver : BroadcastReceiver() {
}
}
}
+
+ companion object {
+ const val ACTION_END = "expo.modules.bbpnotify.END"
+ const val ACTION_EXTEND = "expo.modules.bbpnotify.EXTEND"
+ }
}
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt
new file mode 100644
index 0000000..c795efc
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt
@@ -0,0 +1,31 @@
+package expo.modules.bbpnotify
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * Brings the parking countdown back after a reboot or an app update, without the
+ * user having to open the app.
+ *
+ * `specialUse` is one of the foreground-service types Android 14/15 still allow to
+ * be started from BOOT_COMPLETED, so the service can claim its notification here.
+ * [BbpSessionStore.load] returns null for an already-expired session, so a stale
+ * record from yesterday's parking never resurrects itself.
+ */
+class BbpBootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
+ else -> return
+ }
+
+ if (BbpSessionStore.load(context) == null) {
+ // No live session — make sure an expired record doesn't linger.
+ if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
+ return
+ }
+
+ BbpSessionService.start(context)
+ }
+}
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt
new file mode 100644
index 0000000..29b61dc
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt
@@ -0,0 +1,92 @@
+package expo.modules.bbpnotify
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+
+/**
+ * Builds the ongoing parking notification.
+ *
+ * The remaining time is an Android **chronometer counting down** to the session
+ * end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
+ * end time to the system, which redraws the ticking text itself every second. The
+ * app burns no CPU and needs no timer of its own, so the countdown stays accurate
+ * while the app is closed.
+ *
+ * Shared by the foreground service and by the boot receiver's fallback path.
+ */
+internal object BbpNotification {
+ const val CHANNEL_ID = "session-status"
+ const val NOTIF_ID = 42421
+
+ fun ensureChannel(ctx: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val mgr = ctx.getSystemService(NotificationManager::class.java)
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel =
+ NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
+ channel.setShowBadge(false)
+ mgr.createNotificationChannel(channel)
+ }
+ }
+
+ fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
+ ensureChannel(ctx)
+ val builder = base(ctx)
+ .setContentTitle(s.title)
+ .setContentText(s.body)
+ .setWhen(s.endMs)
+ .setShowWhen(true)
+ .setUsesChronometer(true)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ builder.setChronometerCountDown(true)
+ }
+
+ ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
+ builder.setContentIntent(
+ PendingIntent.getActivity(
+ ctx, 0, launch,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ }
+
+ builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
+ builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
+ return builder.build()
+ }
+
+ /**
+ * A bare notification for the start-then-immediately-stop path. A service launched
+ * with startForegroundService() must call startForeground() within a few seconds or
+ * the system kills the process, and that holds even when we've just discovered there
+ * is no session to show — so we post this and remove it in the same breath. It is
+ * never on screen long enough to be seen.
+ */
+ fun placeholder(ctx: Context): Notification {
+ ensureChannel(ctx)
+ return base(ctx).setContentTitle("Parking").build()
+ }
+
+ private fun base(ctx: Context) =
+ NotificationCompat.Builder(ctx, CHANNEL_ID)
+ .setSmallIcon(R.drawable.bbp_stat_parking)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+
+ private fun actionIntent(ctx: Context, action: String): PendingIntent {
+ val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
+ return PendingIntent.getBroadcast(
+ ctx, action.hashCode(), intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+}
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt
index 914fae9..e968b07 100644
--- a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotifyModule.kt
@@ -1,138 +1,63 @@
package expo.modules.bbpnotify
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
- * Posts an ongoing notification whose "time" is a native Android chronometer
- * counting DOWN to an end time — the system ticks it every second with no app
- * CPU/battery, so it works while the app is closed.
+ * JS bridge for the ongoing parking notification.
*
- * Two flavours: a plain paid-session countdown (`showCountdown`) and a free
- * check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons.
- * The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the
- * choice in SharedPreferences; JS reads it via `consumePendingAction` on the
- * next foreground. Uses only platform APIs — no third-party dependencies.
+ * The module itself holds no state — it writes the session to [BbpSessionStore] and
+ * lets [BbpSessionService] render it. That keeps the app process out of the loop:
+ * the notification is just as correct after the JS engine is gone as it is while
+ * the user is looking at the app. Uses only platform APIs, no third-party deps.
*/
class BbpNotifyModule : Module() {
override fun definition() = ModuleDefinition {
Name("BbpNotify")
- // Returns a short diagnostic string so JS can log exactly what happened
- // (posted / notifications-disabled / exception) — notify() fails silently.
- AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
+ /**
+ * Post (or replace) the countdown for an active session. Returns a short
+ * diagnostic string so JS can log exactly what happened — notifications and
+ * service starts both fail silently otherwise.
+ */
+ AsyncFunction("showSession") {
+ title: String, body: String, endTimeMillis: Double, endLabel: String, extendLabel: String ->
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
- postCountdown(ctx, title, body, endTimeMillis, null, null)
+ val endMs = endTimeMillis.toLong()
+ if (endMs <= System.currentTimeMillis()) {
+ // Nothing to count down to — treat it as "no session" rather than posting
+ // a notification that is already at zero.
+ BbpSessionStore.clear(ctx)
+ BbpSessionService.stop(ctx)
+ return@AsyncFunction "expired"
+ }
+ BbpSessionStore.save(
+ ctx,
+ BbpSessionStore.Session(title, body, endMs, endLabel, extendLabel),
+ )
+ BbpSessionService.start(ctx)
}
- // Same ticking countdown, plus "End" and "Pay" action buttons.
- AsyncFunction("showCheckin") {
- title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String ->
- val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
- postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel)
- }
-
- AsyncFunction("clear") {
- val ctx = appContext.reactContext
- if (ctx != null) {
- NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
+ /** No session: drop the record and stop the service, taking the notification with it. */
+ AsyncFunction("clearSession") {
+ // No bare `return@AsyncFunction` here: the lambda's inferred return type is
+ // Any?, so an early return of Unit doesn't type-check.
+ appContext.reactContext?.let { ctx ->
+ BbpSessionStore.clear(ctx)
+ BbpSessionService.stop(ctx)
}
}
- // Read + clear the action a notification button set while the app was away.
- // Returns "end", "pay", or "".
+ /** Read + clear the action a notification button set. Returns "end", "extend", or "". */
AsyncFunction("consumePendingAction") {
val ctx = appContext.reactContext ?: return@AsyncFunction ""
- val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- val action = prefs.getString(PREF_PENDING, "") ?: ""
- if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply()
- action
- }
- }
-
- private fun postCountdown(
- ctx: Context,
- title: String,
- body: String,
- endTimeMillis: Double,
- endLabel: String?,
- payLabel: String?,
- ): String {
- ensureChannel(ctx)
- val mgr = NotificationManagerCompat.from(ctx)
- val enabled = mgr.areNotificationsEnabled()
-
- val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
- .setContentTitle(title)
- .setContentText(body)
- .setSmallIcon(R.drawable.bbp_stat_parking)
- .setOngoing(true)
- .setOnlyAlertOnce(true)
- .setShowWhen(true)
- .setWhen(endTimeMillis.toLong())
- .setUsesChronometer(true)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setCategory(NotificationCompat.CATEGORY_STATUS)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- builder.setChronometerCountDown(true)
+ BbpSessionStore.consumePending(ctx)
}
- ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
- builder.setContentIntent(
- PendingIntent.getActivity(
- ctx, 0, launch,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- ),
- )
+ /** True when a live session record exists — lets JS reconcile after a cold start. */
+ AsyncFunction("hasActiveSession") {
+ val ctx = appContext.reactContext ?: return@AsyncFunction false
+ BbpSessionStore.load(ctx) != null
}
-
- if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END))
- if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY))
-
- return try {
- mgr.notify(NOTIF_ID, builder.build())
- "posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
- } catch (e: SecurityException) {
- "security-exception enabled=$enabled msg=${e.message}"
- } catch (e: Exception) {
- "exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
- }
- }
-
- private fun actionIntent(ctx: Context, action: String): PendingIntent {
- val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
- return PendingIntent.getBroadcast(
- ctx, action.hashCode(), intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- }
-
- private fun ensureChannel(ctx: Context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val mgr = ctx.getSystemService(NotificationManager::class.java)
- if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
- val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
- channel.setShowBadge(false)
- mgr.createNotificationChannel(channel)
- }
- }
- }
-
- companion object {
- private const val CHANNEL_ID = "session-status"
- const val NOTIF_ID = 42421
- const val PREFS_NAME = "bbp_notify"
- const val PREF_PENDING = "pending_action"
- const val ACTION_END = "expo.modules.bbpnotify.END"
- const val ACTION_PAY = "expo.modules.bbpnotify.PAY"
}
}
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt
new file mode 100644
index 0000000..e3d43ad
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt
@@ -0,0 +1,149 @@
+package expo.modules.bbpnotify
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+
+/**
+ * Foreground service that owns the ongoing parking notification.
+ *
+ * A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
+ * notification away, and nothing brings it back after a reboot. A foreground
+ * service pins the notification for as long as the service runs — the same reason
+ * ntfy's notification stays put on GrapheneOS.
+ *
+ * The service's lifetime is exactly the session's lifetime. It runs while there is
+ * an active session and fully stops — removing the notification — the moment there
+ * isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
+ * never lingers with nothing to show.
+ */
+class BbpSessionService : Service() {
+ private val handler = Handler(Looper.getMainLooper())
+ private var expiryTask: Runnable? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // A null intent means the system restarted us after killing the process
+ // (START_STICKY) — fall through and rebuild from the store.
+ val stopping = intent?.action == ACTION_STOP
+ val session = if (stopping) null else BbpSessionStore.load(this)
+
+ if (session == null) {
+ // Satisfy the startForegroundService() contract before standing down, or the
+ // system kills us for not calling startForeground() in time.
+ goForeground(BbpNotification.placeholder(this))
+ if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
+ shutdown()
+ return START_NOT_STICKY
+ }
+
+ if (!goForeground(BbpNotification.build(this, session))) {
+ // The system refused to let us hold a foreground service right now. The
+ // countdown matters more than the pinning, so leave the notification posted
+ // on its own and stand the service down rather than crash.
+ postDirectly(this)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ scheduleExpiry(session.endMs)
+ return START_STICKY
+ }
+
+ /**
+ * startForeground() can still throw on Android 12+ even after startForegroundService()
+ * was accepted (background-start restrictions are evaluated here too). An uncaught
+ * throw would crash the app, so report the failure instead and let the caller fall back.
+ */
+ private fun goForeground(notification: android.app.Notification): Boolean =
+ try {
+ startForeground(BbpNotification.NOTIF_ID, notification)
+ true
+ } catch (e: Exception) {
+ false
+ }
+
+ /**
+ * Stop the moment the meter runs out, so a finished session never leaves a dead
+ * "0:00" notification pinned to the shade. A posted callback is enough here — a
+ * foreground service is alive to run it, no alarm permission needed.
+ */
+ private fun scheduleExpiry(endMs: Long) {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ val task = Runnable {
+ BbpSessionStore.clear(this)
+ shutdown()
+ }
+ expiryTask = task
+ handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
+ }
+
+ private fun shutdown() {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ expiryTask = null
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ expiryTask?.let { handler.removeCallbacks(it) }
+ expiryTask = null
+ super.onDestroy()
+ }
+
+ companion object {
+ const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
+
+ /**
+ * Start (or refresh) the service from the stored session. Returns a short
+ * diagnostic string — JS logs it, because a failure here is otherwise silent.
+ */
+ fun start(ctx: Context): String {
+ val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
+ val intent = Intent(ctx, BbpSessionService::class.java)
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ctx.startForegroundService(intent)
+ } else {
+ ctx.startService(intent)
+ }
+ "service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
+ } catch (e: Exception) {
+ // Background-start restrictions can refuse the service (Android 12+). The
+ // countdown still matters more than the pinning, so fall back to posting the
+ // notification directly; it just becomes swipe-dismissable.
+ val fallback = postDirectly(ctx)
+ "service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
+ }
+ }
+
+ fun stop(ctx: Context) {
+ try {
+ ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
+ } catch (_: Exception) {
+ // The service may already be gone; make sure the notification is too.
+ }
+ NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
+ }
+
+ /** Last-resort path: the plain ongoing notification, with no service pinning it. */
+ fun postDirectly(ctx: Context): String {
+ val session = BbpSessionStore.load(ctx) ?: return "no-session"
+ return try {
+ NotificationManagerCompat.from(ctx)
+ .notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
+ "posted"
+ } catch (e: SecurityException) {
+ "no-permission"
+ } catch (e: Exception) {
+ "failed=${e.javaClass.simpleName}"
+ }
+ }
+ }
+}
diff --git a/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt
new file mode 100644
index 0000000..7267253
--- /dev/null
+++ b/app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt
@@ -0,0 +1,86 @@
+package expo.modules.bbpnotify
+
+import android.content.Context
+
+/**
+ * The single active-parking record the foreground service renders.
+ *
+ * It lives in SharedPreferences rather than in memory because every other actor
+ * here can run without the JS app: the service after a system restart, the boot
+ * receiver after a reboot, the action receiver after the process is gone. They
+ * all read the same row, so there is exactly one source of truth for "is there a
+ * session, and what does its notification say".
+ */
+internal object BbpSessionStore {
+ const val PREFS = "bbp_notify"
+
+ private const val K_TITLE = "title"
+ private const val K_BODY = "body"
+ private const val K_END = "end_ms"
+ private const val K_END_LABEL = "end_label"
+ private const val K_EXTEND_LABEL = "extend_label"
+ private const val K_PENDING = "pending_action"
+
+ data class Session(
+ val title: String,
+ val body: String,
+ val endMs: Long,
+ val endLabel: String,
+ val extendLabel: String,
+ )
+
+ private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ fun save(ctx: Context, s: Session) {
+ prefs(ctx).edit()
+ .putString(K_TITLE, s.title)
+ .putString(K_BODY, s.body)
+ .putLong(K_END, s.endMs)
+ .putString(K_END_LABEL, s.endLabel)
+ .putString(K_EXTEND_LABEL, s.extendLabel)
+ .apply()
+ }
+
+ /**
+ * The current session, or null when there isn't one. A record whose end time has
+ * already passed reads as null so an expired session can never re-post itself
+ * (e.g. a reboot hours after the meter ran out).
+ */
+ fun load(ctx: Context): Session? {
+ val p = prefs(ctx)
+ val end = p.getLong(K_END, 0L)
+ if (end <= System.currentTimeMillis()) return null
+ return Session(
+ title = p.getString(K_TITLE, "") ?: "",
+ body = p.getString(K_BODY, "") ?: "",
+ endMs = end,
+ endLabel = p.getString(K_END_LABEL, "End") ?: "End",
+ extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
+ )
+ }
+
+ /** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
+ fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
+
+ fun clear(ctx: Context) {
+ prefs(ctx).edit()
+ .remove(K_TITLE)
+ .remove(K_BODY)
+ .remove(K_END)
+ .remove(K_END_LABEL)
+ .remove(K_EXTEND_LABEL)
+ .apply()
+ }
+
+ fun setPending(ctx: Context, action: String) {
+ prefs(ctx).edit().putString(K_PENDING, action).apply()
+ }
+
+ /** Read + clear the action a notification button recorded while the app was away. */
+ fun consumePending(ctx: Context): String {
+ val p = prefs(ctx)
+ val action = p.getString(K_PENDING, "") ?: ""
+ if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
+ return action
+ }
+}
diff --git a/app/modules/bbp-notify/index.ts b/app/modules/bbp-notify/index.ts
index cf085e4..cdf9d1f 100644
--- a/app/modules/bbp-notify/index.ts
+++ b/app/modules/bbp-notify/index.ts
@@ -3,25 +3,26 @@ import { requireOptionalNativeModule } from 'expo-modules-core';
interface BbpNotifyNative {
/**
- * Post/replace an ongoing notification with a native chronometer counting down
- * to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
+ * Post/replace the ongoing session notification: a native chronometer counting
+ * down to endTimeMillis, pinned by a foreground service, with "End" and "Extend"
+ * action buttons. Returns a short diagnostic string (e.g. "service-started …").
*/
- showCountdown(title: string, body: string, endTimeMillis: number): Promise;
- /** Same ticking countdown, plus "End" / "Pay" action buttons. */
- showCheckin(
+ showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
- payLabel: string,
+ extendLabel: string,
): Promise;
- /** Remove the countdown notification. */
- clear(): Promise;
- /** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
+ /** No active session: stop the service and remove the notification. */
+ clearSession(): Promise;
+ /** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
consumePendingAction(): Promise;
+ /** Whether the native side still holds a live (unexpired) session record. */
+ hasActiveSession(): Promise;
}
-export type PendingAction = 'end' | 'pay' | null;
+export type PendingAction = 'end' | 'extend' | null;
// Android-only, and only present in a build that includes the native module
// (returns null in Expo Go / other platforms — callers degrade gracefully).
@@ -33,32 +34,27 @@ const native =
/** True when the native ticking-countdown module is available. */
export const hasNativeCountdown = native != null;
-export async function showCountdown(
- title: string,
- body: string,
- endTimeMillis: number,
-): Promise {
- if (!native) return 'no-native-module';
- return native.showCountdown(title, body, endTimeMillis);
-}
-
-export async function showCheckin(
+export async function showSession(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
- payLabel: string,
+ extendLabel: string,
): Promise {
if (!native) return 'no-native-module';
- return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
+ return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
}
-export async function clearCountdown(): Promise {
- await native?.clear();
+export async function clearSession(): Promise {
+ await native?.clearSession();
}
/** Read + clear a notification-button action taken while the app was away. */
export async function consumePendingAction(): Promise {
const a = await native?.consumePendingAction();
- return a === 'end' || a === 'pay' ? a : null;
+ return a === 'end' || a === 'extend' ? a : null;
+}
+
+export async function hasActiveSession(): Promise {
+ return (await native?.hasActiveSession()) ?? false;
}
diff --git a/app/src/api/parseTime.ts b/app/src/api/parseTime.ts
new file mode 100644
index 0000000..391cc89
--- /dev/null
+++ b/app/src/api/parseTime.ts
@@ -0,0 +1,30 @@
+/**
+ * Parse a ParkSmarter date into a local Date. The API uses TWO formats:
+ * /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
+ * /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
+ * Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
+ * build the Date from explicit components.
+ *
+ * This is the single parser for both — an earlier stricter copy that required
+ * dashes and AM/PM silently returned null on the other format, which meant no
+ * countdown notification at all for sessions whose end time came back that way.
+ */
+export function parseApiTime(s?: string | null): Date | null {
+ if (!s) return null;
+ const m = String(s).match(
+ /(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
+ );
+ if (!m) {
+ const d = new Date(s);
+ return Number.isNaN(+d) ? null : d;
+ }
+ let hr = parseInt(m[4], 10);
+ const ap = m[7];
+ if (ap) {
+ const pm = /pm/i.test(ap);
+ if (pm && hr !== 12) hr += 12;
+ if (!pm && hr === 12) hr = 0;
+ }
+ const sec = m[6] ? parseInt(m[6], 10) : 0;
+ return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
+}
diff --git a/app/src/features/checkin/checkin.ts b/app/src/features/checkin/checkin.ts
deleted file mode 100644
index fd15133..0000000
--- a/app/src/features/checkin/checkin.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useEffect } from 'react';
-import { AppState, Platform } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import * as Notifications from 'expo-notifications';
-import type { Zone } from 'parksmarter-client';
-import type { RootStackParamList } from '@/navigation/RootNavigator';
-import {
- showCheckin,
- clearCountdown,
- consumePendingAction,
- hasNativeCountdown,
-} from '../../../modules/bbp-notify';
-import { getReminderLeadMinutes, getRemindersEnabled, getCountdownEnabled } from '@/features/notifications/reminderPrefs';
-import { ensureNotificationPermission } from '@/notifications/localReminders';
-import { logLine } from '@/features/diagnostics/fileLogger';
-import { labelHours, type LabelKind } from '@/api/zoneLabels';
-import { getCheckin, setCheckin, clearCheckinState, type CheckinState } from './checkinStore';
-
-const ALERT_ID = 'checkin-alert';
-const FALLBACK_NOTIF_ID = 'checkin-status';
-
-function fmtTime(ms: number): string {
- return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
-}
-
-/** Post (or re-post) the ongoing ticking check-in notification with End/Pay buttons. */
-async function postCheckinNotification(s: CheckinState): Promise {
- if (!(await getCountdownEnabled())) return;
- const ends = fmtTime(s.endMs);
- if (hasNativeCountdown) {
- const diag = await showCheckin(`Free parking · ${s.zoneName}`, `Free until ${ends}`, s.endMs, 'End', 'Pay');
- logLine(`[CHECKIN] native: ${diag}`);
- return;
- }
- // Fallback (Expo Go / non-native): a static ongoing notification, no buttons.
- await Notifications.scheduleNotificationAsync({
- identifier: FALLBACK_NOTIF_ID,
- content: {
- title: `Free parking · ${s.zoneName}`,
- body: `Free until ${ends}`,
- sticky: true,
- autoDismiss: false,
- data: { kind: 'session-status' },
- },
- trigger:
- Platform.OS === 'android'
- ? { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(Date.now() + 400), channelId: 'session-status' }
- : null,
- });
-}
-
-async function scheduleCheckinAlert(s: CheckinState): Promise {
- await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
- if (!(await getRemindersEnabled())) return;
- const fireAt = s.endMs - s.leadMinutes * 60_000;
- if (fireAt <= Date.now()) return;
- await Notifications.scheduleNotificationAsync({
- identifier: ALERT_ID,
- content: {
- title: 'Free parking ending soon',
- body: `${s.zoneName}: your free time ends at ${fmtTime(s.endMs)}. Pay to extend or move your car.`,
- data: { kind: 'checkin-alert' },
- },
- trigger: {
- type: Notifications.SchedulableTriggerInputTypes.DATE,
- date: new Date(fireAt),
- channelId: 'session-reminders',
- },
- });
-}
-
-/** Start a local free check-in for `hours` at the given zone. */
-export async function startCheckin(zone: Zone, hours: number): Promise {
- const now = Date.now();
- const state: CheckinState = {
- zone,
- zoneName: zone.ZoneName ?? 'Parking',
- startMs: now,
- endMs: now + hours * 3_600_000,
- kind: `free_${hours}h` as LabelKind,
- leadMinutes: await getReminderLeadMinutes(),
- };
- await setCheckin(state);
- await ensureNotificationPermission();
- await postCheckinNotification(state);
- await scheduleCheckinAlert(state);
-}
-
-/** End the active check-in and clear its notifications. */
-export async function endCheckin(): Promise {
- await clearCheckinState();
- await clearCountdown().catch(() => {});
- await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
- await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
-}
-
-/** Hours of free time for a label kind, or null for pay-immediate. */
-export { labelHours };
-
-/**
- * Keep the check-in in sync on foreground: apply any notification-button action
- * (End clears it; Pay hands off to the paid flow), drop expired check-ins, and
- * re-post the ongoing notification (e.g. after a reboot). Mirrors useSessionStatusSync.
- */
-export function useCheckinSync(): void {
- const navigation = useNavigation>();
- useEffect(() => {
- const run = async () => {
- const action = await consumePendingAction();
- if (action === 'end') {
- await endCheckin();
- return;
- }
- if (action === 'pay') {
- const s = await getCheckin();
- await endCheckin();
- if (s?.zone) navigation.navigate('StartSession', { zone: s.zone });
- return;
- }
- const s = await getCheckin();
- if (!s) return;
- if (s.endMs <= Date.now()) {
- await endCheckin();
- } else {
- await postCheckinNotification(s);
- }
- };
- void run();
- const sub = AppState.addEventListener('change', (state) => {
- if (state === 'active') void run();
- });
- return () => sub.remove();
- }, [navigation]);
-}
diff --git a/app/src/features/checkin/checkinStore.ts b/app/src/features/checkin/checkinStore.ts
deleted file mode 100644
index adf0f32..0000000
--- a/app/src/features/checkin/checkinStore.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import type { Zone } from 'parksmarter-client';
-import type { LabelKind } from '@/api/zoneLabels';
-
-/**
- * A local, API-free "check-in" for a free time-limited space. The full Zone is
- * stored so the notification's "Pay" button can hand off straight into the paid
- * flow without re-fetching. Only one active check-in at a time.
- */
-const KEY = 'ps_checkin';
-
-export interface CheckinState {
- zone: Zone;
- zoneName: string;
- startMs: number;
- endMs: number;
- kind: LabelKind;
- leadMinutes: number;
-}
-
-export async function getCheckin(): Promise {
- const raw = await AsyncStorage.getItem(KEY);
- return raw ? (JSON.parse(raw) as CheckinState) : null;
-}
-
-export async function setCheckin(state: CheckinState): Promise {
- await AsyncStorage.setItem(KEY, JSON.stringify(state));
-}
-
-export async function clearCheckinState(): Promise {
- await AsyncStorage.removeItem(KEY);
-}
diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts
new file mode 100644
index 0000000..7485eeb
--- /dev/null
+++ b/app/src/features/session/activeParking.ts
@@ -0,0 +1,291 @@
+import { useEffect } from 'react';
+import { AppState, Platform } from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import * as Notifications from 'expo-notifications';
+import type { Zone } from 'parksmarter-client';
+import { ps } from '@/api/client';
+import { parseApiTime } from '@/api/parseTime';
+import type { LabelKind } from '@/api/zoneLabels';
+import type { RootStackParamList } from '@/navigation/RootNavigator';
+import {
+ ensureNotificationPermission,
+ REMINDER_CHANNEL_ID,
+} from '@/notifications/localReminders';
+import {
+ getCountdownEnabled,
+ getReminderLeadMinutes,
+ getRemindersEnabled,
+} from '@/features/notifications/reminderPrefs';
+import { logLine } from '@/features/diagnostics/fileLogger';
+import {
+ clearActiveParking,
+ getActiveParking,
+ setActiveParking,
+ type ActiveParking,
+} from './activeParkingStore';
+import {
+ clearSession,
+ consumePendingAction,
+ hasNativeCountdown,
+ showSession,
+} from '../../../modules/bbp-notify';
+
+/**
+ * Everything that happens while a car is parked, paid or free, lives here.
+ *
+ * There is exactly one active parking session at a time, so there is exactly one
+ * ongoing notification. Both entry points (buying time, checking into a free
+ * space) write the same record and post the same countdown, which is why the
+ * notification behaves identically whichever way you parked.
+ *
+ * The notification itself is owned by a native foreground service — see
+ * modules/bbp-notify. JS's job is to keep the record truthful; the service
+ * renders it and stops itself the moment there's nothing to show.
+ */
+
+/** One session at a time, so one reminder id. */
+const EXPIRY_REMINDER_ID = 'parking-expiry';
+/** Fallback ongoing notification for Expo Go, where the native module is absent. */
+const FALLBACK_NOTIF_ID = 'parking-status';
+
+function fmtTime(ms: number): string {
+ return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+}
+
+/* ------------------------------------------------------------------ posting */
+
+/** Post (or replace) the ongoing ticking notification for a session. */
+async function postNotification(p: ActiveParking): Promise {
+ if (!(await getCountdownEnabled())) {
+ logLine('[PARKING] countdown disabled — not posting');
+ await clearNotification();
+ return;
+ }
+ await ensureNotificationPermission();
+
+ const ends = fmtTime(p.endMs);
+ const free = p.kind === 'free';
+ const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
+ const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
+ // Free time isn't bought, so the button that buys time reads "Pay"; on a paid
+ // session it genuinely extends what you already have.
+ const extendLabel = free ? 'Pay' : 'Extend';
+
+ if (hasNativeCountdown) {
+ const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
+ logLine(`[PARKING] ${p.kind} native: ${diag}`);
+ return;
+ }
+
+ // Expo Go / no native module: a static ongoing notification, no buttons and no
+ // ticking, refreshed whenever the app is foregrounded.
+ logLine('[PARKING] no native module — static fallback notification');
+ await Notifications.scheduleNotificationAsync({
+ identifier: FALLBACK_NOTIF_ID,
+ content: {
+ title,
+ body,
+ sticky: true,
+ autoDismiss: false,
+ data: { kind: 'session-status' },
+ },
+ trigger:
+ Platform.OS === 'android'
+ ? {
+ type: Notifications.SchedulableTriggerInputTypes.DATE,
+ date: new Date(Date.now() + 400),
+ channelId: 'session-status',
+ }
+ : null,
+ });
+}
+
+async function clearNotification(): Promise {
+ await clearSession().catch(() => {});
+ await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
+ await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
+}
+
+/* ---------------------------------------------------------------- reminders */
+
+async function scheduleExpiryReminder(p: ActiveParking): Promise {
+ await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
+ if (!(await getRemindersEnabled())) return;
+ const fireAt = p.endMs - p.leadMinutes * 60_000;
+ if (fireAt <= Date.now()) return;
+
+ const free = p.kind === 'free';
+ await Notifications.scheduleNotificationAsync({
+ identifier: EXPIRY_REMINDER_ID,
+ content: {
+ title: free ? 'Free parking ending soon' : 'Parking expiring soon',
+ body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
+ p.endMs,
+ )}. Extend if you need more time.`,
+ data: { kind: 'parking-expiry' },
+ },
+ trigger: {
+ type: Notifications.SchedulableTriggerInputTypes.DATE,
+ date: new Date(fireAt),
+ channelId: REMINDER_CHANNEL_ID,
+ },
+ });
+}
+
+/* ------------------------------------------------------------ start / stop */
+
+/** Begin tracking a paid session that ParkSmarter has just confirmed. */
+export async function startPaidSession(args: {
+ zone: Zone;
+ endTime: Date;
+ transactionId?: string | number;
+}): Promise {
+ const state: ActiveParking = {
+ kind: 'paid',
+ zone: args.zone,
+ zoneName: args.zone.ZoneName ?? 'Parking',
+ startMs: Date.now(),
+ endMs: args.endTime.getTime(),
+ transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
+ leadMinutes: await getReminderLeadMinutes(),
+ };
+ await setActiveParking(state);
+ await postNotification(state);
+ await scheduleExpiryReminder(state);
+}
+
+/** Start a local free check-in for `hours` at the given zone. */
+export async function startFreeCheckin(
+ zone: Zone,
+ hours: number,
+ labelKind?: LabelKind,
+): Promise {
+ const now = Date.now();
+ const state: ActiveParking = {
+ kind: 'free',
+ zone,
+ zoneName: zone.ZoneName ?? 'Parking',
+ startMs: now,
+ endMs: now + hours * 3_600_000,
+ labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
+ leadMinutes: await getReminderLeadMinutes(),
+ };
+ await setActiveParking(state);
+ await postNotification(state);
+ await scheduleExpiryReminder(state);
+}
+
+/**
+ * Stop tracking the active session and take its notification down.
+ *
+ * For a free check-in this genuinely ends it. For a paid session it only stops
+ * tracking — ParkSmarter has no stop-session endpoint, so the time you bought
+ * keeps running at the meter whether or not the app is showing it.
+ */
+export async function endActiveParking(): Promise {
+ await clearActiveParking();
+ await clearNotification();
+ await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
+}
+
+/* ----------------------------------------------------------------- syncing */
+
+/**
+ * Ask the server whether a paid session is running. Only used when nothing is
+ * tracked locally — a session bought on another device, or before this version
+ * started persisting them. The active-session API returns no Zone, so a session
+ * found this way has no zone to extend into.
+ */
+async function discoverPaidSession(): Promise {
+ try {
+ const res = await ps.getActiveParkingSessions();
+ const soonest = (res.ParkingSession ?? [])
+ .map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
+ .filter((x) => x.end != null && x.end.getTime() > Date.now())
+ .sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
+ if (!soonest) return null;
+
+ const found: ActiveParking = {
+ kind: 'paid',
+ zoneName: soonest.s.ZoneName ?? 'Parking',
+ startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
+ endMs: soonest.end!.getTime(),
+ transactionId:
+ soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
+ leadMinutes: await getReminderLeadMinutes(),
+ };
+ logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
+ await setActiveParking(found);
+ await scheduleExpiryReminder(found);
+ return found;
+ } catch (e: any) {
+ logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
+ return null;
+ }
+}
+
+/**
+ * Reconcile the record, its notification and any button the user pressed while the
+ * app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
+ */
+export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise {
+ const action = await consumePendingAction();
+
+ if (action === 'end') {
+ logLine('[PARKING] notification "End" pressed');
+ await endActiveParking();
+ return;
+ }
+
+ let current = await getActiveParking();
+
+ // The meter ran out: the service already removed its own notification when the
+ // countdown hit zero, so this just clears the record behind it.
+ if (current && current.endMs <= Date.now()) {
+ await endActiveParking();
+ current = null;
+ }
+
+ if (!current) current = await discoverPaidSession();
+
+ if (current) {
+ await postNotification(current);
+ } else {
+ await clearNotification();
+ }
+
+ if (action === 'extend') {
+ logLine('[PARKING] notification "Extend" pressed');
+ onExtend(current?.zone);
+ }
+}
+
+/** Called by the tab navigator: sync on open and on every foreground. */
+export function useActiveParkingSync(): void {
+ const navigation = useNavigation>();
+
+ useEffect(() => {
+ const run = () =>
+ void syncActiveParking((zone) => {
+ // "Extend" means "sell me more time for this exact spot" — go straight to
+ // the purchase screen for the stored zone. A server-discovered session has
+ // no zone, so fall back to the sessions list rather than guessing.
+ if (zone) navigation.navigate('StartSession', { zone });
+ else navigation.navigate('Tabs');
+ });
+
+ run();
+ const sub = AppState.addEventListener('change', (state) => {
+ if (state === 'active') run();
+ });
+ return () => sub.remove();
+ }, [navigation]);
+}
+
+/** Turn the countdown notification on/off from Settings without touching the record. */
+export async function refreshParkingNotification(): Promise {
+ const current = await getActiveParking();
+ if (current && current.endMs > Date.now()) await postNotification(current);
+ else await clearNotification();
+}
diff --git a/app/src/features/session/activeParkingStore.ts b/app/src/features/session/activeParkingStore.ts
new file mode 100644
index 0000000..7f348db
--- /dev/null
+++ b/app/src/features/session/activeParkingStore.ts
@@ -0,0 +1,57 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { Zone } from 'parksmarter-client';
+import type { LabelKind } from '@/api/zoneLabels';
+
+/**
+ * The one parking session the app is currently tracking — paid or a local free
+ * check-in. Persisting it locally is what lets the countdown come back after a
+ * reboot, offline, or in Anonymous Mode: the notification no longer depends on a
+ * ParkSmarter round-trip to know a session exists.
+ *
+ * The full Zone is stored so the notification's "Extend" button can hand straight
+ * into the paid flow without re-fetching the meter.
+ */
+const KEY = 'ps_active_parking';
+/** Pre-0.5 free check-ins lived here; read once so an in-flight check-in survives the upgrade. */
+const LEGACY_CHECKIN_KEY = 'ps_checkin';
+
+export type ParkingKind = 'paid' | 'free';
+
+export interface ActiveParking {
+ kind: ParkingKind;
+ /**
+ * Absent only for a paid session discovered from the server (the active-session
+ * API returns no zone), in which case "Extend" falls back to the Sessions tab.
+ */
+ zone?: Zone;
+ zoneName: string;
+ startMs: number;
+ endMs: number;
+ /** Paid only: the ParkSmarter transaction id, for receipts/debugging. */
+ transactionId?: string;
+ /** Free only: which zone-label this check-in came from. */
+ labelKind?: LabelKind;
+ /** Minutes before expiry to fire the local reminder. */
+ leadMinutes: number;
+}
+
+export async function getActiveParking(): Promise {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (raw) return JSON.parse(raw) as ActiveParking;
+
+ const legacy = await AsyncStorage.getItem(LEGACY_CHECKIN_KEY);
+ if (!legacy) return null;
+ const c = JSON.parse(legacy) as Omit;
+ const migrated: ActiveParking = { ...c, kind: 'free' };
+ await AsyncStorage.setItem(KEY, JSON.stringify(migrated));
+ await AsyncStorage.removeItem(LEGACY_CHECKIN_KEY);
+ return migrated;
+}
+
+export async function setActiveParking(state: ActiveParking): Promise {
+ await AsyncStorage.setItem(KEY, JSON.stringify(state));
+}
+
+export async function clearActiveParking(): Promise {
+ await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY]);
+}
diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx
index 57da0bd..7475d99 100644
--- a/app/src/navigation/RootNavigator.tsx
+++ b/app/src/navigation/RootNavigator.tsx
@@ -25,8 +25,7 @@ import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { AdminScreen } from '@/screens/AdminScreen';
import { useTheme } from '@/theme/ThemeContext';
-import { useSessionStatusSync } from '@/notifications/sessionStatus';
-import { useCheckinSync } from '@/features/checkin/checkin';
+import { useActiveParkingSync } from '@/features/session/activeParking';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
export type RootStackParamList = {
@@ -72,10 +71,9 @@ const TAB_ICONS: Record = {
};
function Tabs() {
- // Keep the ongoing "time left" status notification fresh (on open + foreground).
- useSessionStatusSync();
- // Apply check-in notification actions (End/Pay) + keep its countdown in sync.
- useCheckinSync();
+ // Keep the ongoing countdown in sync (on open + every foreground) and apply any
+ // End/Extend the user pressed on the notification while the app was away.
+ useActiveParkingSync();
return (
({
diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts
index 2b39cd0..6e5af97 100644
--- a/app/src/notifications/localReminders.ts
+++ b/app/src/notifications/localReminders.ts
@@ -1,9 +1,5 @@
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
-import {
- getReminderLeadMinutes,
- getRemindersEnabled,
-} from '@/features/notifications/reminderPrefs';
/**
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
@@ -11,7 +7,9 @@ import {
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
*/
-const CHANNEL_ID = 'session-reminders';
+/** Exported so the active-parking module can schedule onto the same channel. */
+export const REMINDER_CHANNEL_ID = 'session-reminders';
+const CHANNEL_ID = REMINDER_CHANNEL_ID;
/** Android 8+ needs a notification channel; ensure it exists once. */
async function ensureChannel(): Promise {
@@ -48,48 +46,6 @@ Notifications.setNotificationHandler({
},
});
-export interface ScheduleReminderArgs {
- transactionId: string | number;
- zoneName: string;
- /** When the parking session ends. */
- endTime: Date;
- /** Override the user's configured lead time (minutes before end). */
- leadMinutes?: number;
-}
-
-/**
- * Schedule an expiry reminder using the user's Notifications preferences
- * (lead time + enabled). Returns the notification id, or null if disabled/late.
- */
-export async function scheduleExpiryReminder(
- args: ScheduleReminderArgs,
-): Promise {
- if (!(await getRemindersEnabled())) return null;
- const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
- const lead = leadMinutes * 60 * 1000;
- const fireAt = new Date(args.endTime.getTime() - lead);
- if (fireAt.getTime() <= Date.now()) return null; // already too late
-
- await ensureChannel();
- return Notifications.scheduleNotificationAsync({
- identifier: `session-${args.transactionId}`,
- content: {
- title: 'Parking expiring soon',
- body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
- data: { transactionId: String(args.transactionId) },
- },
- trigger: {
- type: Notifications.SchedulableTriggerInputTypes.DATE,
- date: fireAt,
- channelId: CHANNEL_ID,
- },
- });
-}
-
-export async function cancelExpiryReminder(transactionId: string | number): Promise {
- await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
-}
-
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
export async function sendTestReminder(seconds = 10): Promise {
if (!(await ensureNotificationPermission())) return false;
diff --git a/app/src/notifications/sessionStatus.ts b/app/src/notifications/sessionStatus.ts
deleted file mode 100644
index 98d4c08..0000000
--- a/app/src/notifications/sessionStatus.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useEffect } from 'react';
-import { AppState, Platform } from 'react-native';
-import * as Notifications from 'expo-notifications';
-import { ps } from '@/api/client';
-import { getCountdownEnabled } from '@/features/notifications/reminderPrefs';
-import { ensureNotificationPermission } from '@/notifications/localReminders';
-import { logLine } from '@/features/diagnostics/fileLogger';
-import { hasNativeCountdown, showCountdown, clearCountdown } from '../../modules/bbp-notify';
-
-/**
- * Ongoing "time left" status notification while a parking session is active.
- *
- * Preferred path: a tiny native module (modules/bbp-notify) posts a notification
- * whose time is an Android **chronometer counting down** to the session end — the
- * system ticks it every second with no app running (glance without opening the app).
- * If that native module isn't present, we fall back to an expo-notifications
- * ongoing notification showing the (static) expiry time, refreshed on foreground.
- */
-
-const CHANNEL = 'session-status';
-const NOTIF_ID = 'session-status';
-
-async function ensureExpoChannel(): Promise {
- if (Platform.OS !== 'android') return;
- await Notifications.setNotificationChannelAsync(CHANNEL, {
- name: 'Active parking',
- importance: Notifications.AndroidImportance.LOW,
- showBadge: false,
- });
-}
-
-/**
- * Parse a ParkSmarter date into a local Date. The API uses TWO formats:
- * /api/Session (past): "07-15-2026 05:00 PM" dashes, 12h, AM/PM
- * /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
- * Both are device-local wall-clock time. Hermes' Date() can't parse either, so
- * we build the Date from explicit components.
- */
-export function parseApiTime(s?: string | null): Date | null {
- if (!s) return null;
- const m = String(s).match(
- /(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
- );
- if (!m) {
- const d = new Date(s);
- return Number.isNaN(+d) ? null : d;
- }
- let hr = parseInt(m[4], 10);
- const ap = m[7];
- if (ap) {
- const pm = /pm/i.test(ap);
- if (pm && hr !== 12) hr += 12;
- if (!pm && hr === 12) hr = 0;
- }
- const sec = m[6] ? parseInt(m[6], 10) : 0;
- return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
-}
-
-function fmtLeft(min: number): string {
- if (min <= 0) return 'expired';
- const h = Math.floor(min / 60);
- const m = min % 60;
- return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
-}
-
-/** Post (or replace) the ongoing status notification for a session end time. */
-export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise {
- if (!(await getCountdownEnabled())) {
- logLine('[STATUS] skip: countdown disabled');
- return;
- }
- const granted = await ensureNotificationPermission();
- const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
- logLine(
- `[STATUS] show native=${hasNativeCountdown} perm=${granted} end=${args.endTime.toISOString()}`,
- );
-
- if (hasNativeCountdown) {
- // Native chronometer: a live ticking countdown, updated by the OS.
- try {
- const diag = await showCountdown(
- `Parking · ${args.zoneName}`,
- `Expires ${ends}`,
- args.endTime.getTime(),
- );
- logLine(`[STATUS] native result: ${diag}`);
- } catch (e: any) {
- logLine(`[STATUS] native threw: ${e?.message ?? e}`);
- }
- return;
- }
-
- // Fallback: static expiry-time notification, refreshed on foreground.
- await ensureExpoChannel();
- const remainMin = Math.round((args.endTime.getTime() - Date.now()) / 60000);
- await Notifications.scheduleNotificationAsync({
- identifier: NOTIF_ID,
- content: {
- title: remainMin > 0 ? `Parking: ${fmtLeft(remainMin)} left` : 'Parking expired',
- body: `${args.zoneName} · expires ${ends}`,
- sticky: true,
- autoDismiss: false,
- data: { kind: 'session-status' },
- },
- trigger:
- Platform.OS === 'android'
- ? {
- type: Notifications.SchedulableTriggerInputTypes.DATE,
- date: new Date(Date.now() + 400),
- channelId: CHANNEL,
- }
- : null,
- });
-}
-
-export async function clearSessionStatus(): Promise {
- await clearCountdown().catch(() => {});
- await Notifications.dismissNotificationAsync(NOTIF_ID).catch(() => {});
- await Notifications.cancelScheduledNotificationAsync(NOTIF_ID).catch(() => {});
-}
-
-/**
- * Fetch active sessions and re-post the status for the soonest-expiring one,
- * or clear it if there are none / the feature is off.
- */
-export async function refreshSessionStatus(): Promise {
- if (!(await getCountdownEnabled())) {
- await clearSessionStatus();
- return;
- }
- try {
- const res = await ps.getActiveParkingSessions();
- logLine(`[STATUS] active sessions raw: ${JSON.stringify(res.ParkingSession ?? [])}`);
- const withEnd = (res.ParkingSession ?? [])
- .map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
- .filter((x) => x.end != null && x.end.getTime() > Date.now())
- .sort((a, b) => a.end!.getTime() - b.end!.getTime());
- logLine(`[STATUS] parsed ${withEnd.length} future-end session(s) of ${(res.ParkingSession ?? []).length}`);
- if (withEnd.length === 0) {
- await clearSessionStatus();
- return;
- }
- const { s, end } = withEnd[0];
- await showSessionStatus({ zoneName: s.ZoneName ?? s.Zone ?? 'Parking', endTime: end! });
- } catch (e: any) {
- logLine(`[STATUS] refresh failed: ${e?.serverMessage ?? e?.message ?? e}`);
- }
-}
-
-/** Keep the status notification fresh: on mount and each time the app is foregrounded. */
-export function useSessionStatusSync(): void {
- useEffect(() => {
- void refreshSessionStatus();
- const sub = AppState.addEventListener('change', (state) => {
- if (state === 'active') void refreshSessionStatus();
- });
- return () => sub.remove();
- }, []);
-}
diff --git a/app/src/screens/MeterDetailScreen.tsx b/app/src/screens/MeterDetailScreen.tsx
index f5d3fec..b6643e7 100644
--- a/app/src/screens/MeterDetailScreen.tsx
+++ b/app/src/screens/MeterDetailScreen.tsx
@@ -19,7 +19,7 @@ import {
type LabelKind,
type ZoneLabel,
} from '@/api/zoneLabels';
-import { startCheckin } from '@/features/checkin/checkin';
+import { startFreeCheckin } from '@/features/session/activeParking';
/** Admin labeling buttons — kind, or 'clear' to remove. */
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
@@ -140,7 +140,7 @@ export function MeterDetailScreen() {
const labeledHours = label ? labelHours(label.kind) : null;
const doCheckin = async (hours: number) => {
- await startCheckin(z, hours);
+ await startFreeCheckin(z, hours, label?.kind);
Alert.alert(
'Checked in',
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
diff --git a/app/src/screens/NotificationsScreen.tsx b/app/src/screens/NotificationsScreen.tsx
index d9cc7b0..ebe8444 100644
--- a/app/src/screens/NotificationsScreen.tsx
+++ b/app/src/screens/NotificationsScreen.tsx
@@ -3,7 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '@/theme/ThemeContext';
import { sendTestReminder } from '@/notifications/localReminders';
-import { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
+import { refreshParkingNotification } from '@/features/session/activeParking';
import {
DEFAULT_LEAD_MINUTES,
LEAD_STEP,
@@ -38,7 +38,7 @@ export function NotificationsScreen() {
const toggleCountdown = (v: boolean) => {
setCountdown(v);
- void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
+ void setCountdownEnabled(v).then(refreshParkingNotification);
};
const bump = (delta: number) => {
diff --git a/app/src/screens/StartSessionScreen.tsx b/app/src/screens/StartSessionScreen.tsx
index 53d77cd..1d50344 100644
--- a/app/src/screens/StartSessionScreen.tsx
+++ b/app/src/screens/StartSessionScreen.tsx
@@ -14,9 +14,8 @@ import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
-import { scheduleExpiryReminder } from '@/notifications/localReminders';
-import { showSessionStatus } from '@/notifications/sessionStatus';
-import { endCheckin } from '@/features/checkin/checkin';
+import { parseApiTime } from '@/api/parseTime';
+import { startPaidSession } from '@/features/session/activeParking';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import {
@@ -107,18 +106,6 @@ export async function buildSingleLadder(base: {
return { free: false, flat: false, ladder };
}
-/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
-function parseApiTime(s?: string): Date | null {
- if (!s) return null;
- const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
- if (!m) return null;
- let hr = parseInt(m[4], 10);
- const pm = /pm/i.test(m[6]);
- if (pm && hr !== 12) hr += 12;
- if (!pm && hr === 12) hr = 0;
- return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
-}
-
export function StartSessionScreen() {
const { colors } = useTheme();
const navigation = useNavigation