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