From ad55559f55c118650898fc13bcb9001c302a62fd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 20:10:54 +0000 Subject: [PATCH] =?UTF-8?q?v0.5.0:=20persistent=20parking=20notification?= =?UTF-8?q?=20=E2=80=94=20foreground=20service,=20End/Extend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ongoing countdown now appears whenever a car is parked, paid or free, and sticks around the way ntfy's does on GrapheneOS. Why it wasn't showing at all on a paid park: StartSessionScreen carried its own stricter copy of parseApiTime that demanded MM-DD-YYYY + AM/PM. The dual-format fix from deb78bf only landed in sessionStatus.ts, so when the API returned the other format the parser returned null and the notification was simply never posted. There is now one parser (api/parseTime.ts), and the failure logs instead of going silent. Persistence: a plain notify() was never enough — Android 14+ lets the user swipe an ongoing notification away, and nothing brought it back after a reboot. BbpSessionService is a real foreground service (type specialUse) that owns the notification, plus a BOOT_COMPLETED receiver to restore it; specialUse is one of the types Android 14/15 still allow to start from BOOT_COMPLETED. The service's life is exactly the session's life: it stops itself, removing the notification, on End, at expiry, or when there's no session to show. Buttons: paid sessions previously got no actions at all (only free check-ins did). Both now get End and Extend/Pay. End stops tracking — honest about the fact that ParkSmarter has no stop-session endpoint, so bought time keeps running at the meter. Extend opens the purchase screen for that exact zone. The active session (with its Zone) is persisted locally, so the countdown and Extend survive reboot, offline, and Anonymous Mode instead of depending on a round-trip. sessionStatus.ts + features/checkin collapse into one owner, features/session/activeParking — one record, one notification. Verified: :bbp-notify:compileDebugKotlin passes, and the merged app manifest carries the service (specialUse + FGS subtype property), both receivers, and the FOREGROUND_SERVICE/SPECIAL_USE/RECEIVE_BOOT_COMPLETED permissions. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 36 ++- app/app.json | 4 +- .../android/src/main/AndroidManifest.xml | 30 +- .../modules/bbpnotify/BbpActionReceiver.kt | 31 +- .../expo/modules/bbpnotify/BbpBootReceiver.kt | 31 ++ .../expo/modules/bbpnotify/BbpNotification.kt | 92 ++++++ .../expo/modules/bbpnotify/BbpNotifyModule.kt | 151 +++------ .../modules/bbpnotify/BbpSessionService.kt | 149 +++++++++ .../expo/modules/bbpnotify/BbpSessionStore.kt | 86 ++++++ app/modules/bbp-notify/index.ts | 46 ++- app/src/api/parseTime.ts | 30 ++ app/src/features/checkin/checkin.ts | 135 -------- app/src/features/checkin/checkinStore.ts | 32 -- app/src/features/session/activeParking.ts | 291 ++++++++++++++++++ .../features/session/activeParkingStore.ts | 57 ++++ app/src/navigation/RootNavigator.tsx | 10 +- app/src/notifications/localReminders.ts | 50 +-- app/src/notifications/sessionStatus.ts | 159 ---------- app/src/screens/MeterDetailScreen.tsx | 4 +- app/src/screens/NotificationsScreen.tsx | 4 +- app/src/screens/StartSessionScreen.tsx | 33 +- 21 files changed, 897 insertions(+), 564 deletions(-) create mode 100644 app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpBootReceiver.kt create mode 100644 app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpNotification.kt create mode 100644 app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionService.kt create mode 100644 app/modules/bbp-notify/android/src/main/java/expo/modules/bbpnotify/BbpSessionStore.kt create mode 100644 app/src/api/parseTime.ts delete mode 100644 app/src/features/checkin/checkin.ts delete mode 100644 app/src/features/checkin/checkinStore.ts create mode 100644 app/src/features/session/activeParking.ts create mode 100644 app/src/features/session/activeParkingStore.ts delete mode 100644 app/src/notifications/sessionStatus.ts 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