v0.3.0: local free check-in with actionable countdown notification (Phase C)
All checks were successful
build-apk / build (push) Successful in 40m22s

For a zone labeled free_2h/3h/4h, "Check in (free · Xh)" starts a local, API-free
countdown to the free limit — no ParkSmarter call. The bbp-notify module gains a
showCheckin() that posts the ticking chronometer with "End" / "Pay" buttons, a
BbpActionReceiver (declared in the module's new AndroidManifest.xml) that records
the tap in SharedPreferences and, for Pay, relaunches the app; consumePendingAction()
is drained on foreground by useCheckinSync() — End clears the check-in, Pay hands
off to StartSession for the stored zone. A pre-expiry alert reuses the existing
reminder lead-minutes. Starting a paid session supersedes an active check-in.

Also: MeterDetail shows the free/pay-immediate label and (admins) labeling chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-24 18:23:58 +00:00
parent eeaf09ea0e
commit 463facbe5a
10 changed files with 368 additions and 59 deletions

View file

@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Fully-qualified name: relative ".Name" would resolve against the app
package, not this module's namespace, once manifests are merged. -->
<receiver
android:name="expo.modules.bbpnotify.BbpActionReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,31 @@
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.
*/
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)
}
BbpNotifyModule.ACTION_PAY -> {
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(launch)
}
}
}
}
}

View file

@ -4,6 +4,7 @@ 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
@ -11,63 +12,32 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
* A tiny, self-contained module that posts an ongoing notification whose "time"
* is a native Android chronometer counting DOWN to the session's end. The system
* ticks it every second with no app CPU/battery works while the app is closed.
* 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.
*
* Uses only platform APIs (NotificationCompat) no third-party dependencies.
* 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.
*/
class BbpNotifyModule : Module() {
override fun definition() = ModuleDefinition {
Name("BbpNotify")
// Returns a short diagnostic string so the JS layer can log exactly what
// happened (posted / notifications-disabled / exception). "Nothing appears"
// bugs are otherwise invisible because notify() fails silently.
// 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 ->
val ctx = appContext.reactContext
?: return@AsyncFunction "no-context"
ensureChannel(ctx)
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
postCountdown(ctx, title, body, endTimeMillis, null, null)
}
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)
}
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
builder.setContentIntent(
PendingIntent.getActivity(
ctx,
0,
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
return@AsyncFunction try {
mgr.notify(NOTIF_ID, builder.build())
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
} catch (e: SecurityException) {
// POST_NOTIFICATIONS not granted.
"security-exception enabled=$enabled msg=${e.message}"
} catch (e: Exception) {
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
}
// 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") {
@ -76,17 +46,81 @@ class BbpNotifyModule : Module() {
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
}
}
// Read + clear the action a notification button set while the app was away.
// Returns "end", "pay", 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)
}
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
builder.setContentIntent(
PendingIntent.getActivity(
ctx, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
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,
)
val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
mgr.createNotificationChannel(channel)
}
@ -95,6 +129,10 @@ class BbpNotifyModule : Module() {
companion object {
private const val CHANNEL_ID = "session-status"
private const val NOTIF_ID = 42421
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"
}
}

View file

@ -7,10 +7,22 @@ interface BbpNotifyNative {
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
*/
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
showCheckin(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
payLabel: string,
): Promise<string>;
/** Remove the countdown notification. */
clear(): Promise<void>;
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
consumePendingAction(): Promise<string>;
}
export type PendingAction = 'end' | 'pay' | null;
// Android-only, and only present in a build that includes the native module
// (returns null in Expo Go / other platforms — callers degrade gracefully).
const native =
@ -30,6 +42,23 @@ export async function showCountdown(
return native.showCountdown(title, body, endTimeMillis);
}
export async function showCheckin(
title: string,
body: string,
endTimeMillis: number,
endLabel: string,
payLabel: string,
): Promise<string> {
if (!native) return 'no-native-module';
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
}
export async function clearCountdown(): Promise<void> {
await native?.clear();
}
/** Read + clear a notification-button action taken while the app was away. */
export async function consumePendingAction(): Promise<PendingAction> {
const a = await native?.consumePendingAction();
return a === 'end' || a === 'pay' ? a : null;
}