Skip to content

Commit 416a13d

Browse files
committed
feat: sync question status and Android badges
1 parent da458ed commit 416a13d

25 files changed

Lines changed: 324 additions & 94 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The product requirements document is in [`docs/prd/pi-postbox.md`](docs/prd/pi-p
88

99
## Current status
1010

11-
Version 0.2.9 coordinates protocol 0.1.9 with Android 0.4.2 (build 6), generated cross-language conformance fixtures, exact-match version gating, hard-block mismatch UX, durable Answer-driven agent auto-wake, and answer-ready widget cleanup after reads.
11+
Version 0.2.10 coordinates protocol 0.1.9 with Android 0.4.3 (build 7), keeps the current-owner footer count synchronized with Question lifecycle changes, makes configured development and production API ports strict, and adds a privacy-preserving Android notification-backed launcher badge for the full pending queue. It retains generated cross-language conformance fixtures, exact-match version gating, hard-block mismatch UX, durable Answer-driven agent auto-wake, and answer-ready widget cleanup after reads.
1212

1313
Issues #1-#11 provide the v1 implementation: runnable TypeScript workspace, `pi-postbox-server` CLI, Pi extension with `write_question`, WebSocket session registration, SSE browser state, SQLite persistence/history, structured Questions and options, semantic working/blocked/idle state, reconnect/idempotency/expiry, local terminal fallback commands, editable presentation metadata, and packaging/deployment docs plus a release smoke script. Version 0.2.6 updates Android 0.4.1 to include the server-required Question revision when submitting an Answer. Version 0.2.5 keeps the footer and status surfaces from undercounting a locally tracked open Question when the durable owner snapshot is briefly stale, makes published package updates robust on npm 11, and prebuilds the shared protocol before clean-checkout test runs. Version 0.2.3 adds validated npm Trusted Publishing from pushes to the main branch through GitHub Actions OIDC. Version 0.2.2 adds complete npm license and source metadata, fixes the published CLI bin path, and excludes test source from the package tarball. Version 0.2.1 bounds and paginates every model-facing bulk read, uses compact stateless cursors, hides inactive historical owners by default, trims repeated list fields, and preserves checkout development ports across restarts. Version 0.2.0 replaces the separate model-facing create/update tools with one explicit-action `write_question` surface and returns reusable current Question handles from creation. Version 0.1.9 requires a concise ambiguity for new Questions, simplifies parent and expiry inputs, renames option `meaning` to `impact`, removes top-level handoff context and per-option context, removes reconstructed Question Chat, and renders single/multi choice with accessible ballot controls. Version 0.1.8 exposed single-Question create/idempotent receipt disposition and safely required a full Pi restart when `/reload` retained an incompatible shared protocol dependency. Version 0.1.7 reduced model-facing tool schemas, strictly described exact-owner filters, and made recovery reads compact by default with an explicit full view while preserving strict server-side action validation and internal provenance/expiry compatibility.
1414

@@ -26,7 +26,7 @@ Start the local server with defaults:
2626
node packages/server/dist/cli.js
2727
```
2828

29-
The server binds to `127.0.0.1`, treats port `32187` as the canonical default, stores data in `~/.pi-postbox/postbox.sqlite`, and prints the actual listening URL. If port `32187` is already in use, it automatically selects another local port and prints an explicit warning that the local/Tailnet bookmark URL is non-canonical; free `32187` or set `--port` / `PI_POSTBOX_PORT` to a stable available port if you need a bookmarkable URL.
29+
The production server binds to `127.0.0.1`, uses the fixed canonical port `32187`, stores data in `~/.pi-postbox/postbox.sqlite`, and prints the listening URL. If configured port `32187` is already in use, startup fails instead of silently changing the local and Tailnet URLs. Use `--port` / `PI_POSTBOX_PORT` only to configure a deliberate stable alternative.
3030

3131
## Workspace commands
3232

@@ -135,7 +135,7 @@ An answered request, a cancelled request, expiry, or Pi Session replacement thro
135135
Supported server flags and environment variables:
136136

137137
- `--host` or `PI_POSTBOX_HOST` (default `127.0.0.1`)
138-
- `--port` or `PI_POSTBOX_PORT` (preferred default `32187`; falls back to another local port if already in use)
138+
- `--port` or `PI_POSTBOX_PORT` (fixed canonical default `32187`; startup fails if the configured port is already in use)
139139
- `--ui-dist-dir` or `PI_POSTBOX_UI_DIST_DIR` (default packaged `dist/public` beside the server CLI)
140140
- `--database` or `PI_POSTBOX_DATABASE` (default `~/.pi-postbox/postbox.sqlite`)
141141
- `--profile` or `PI_POSTBOX_PROFILE` (`production` or `development:<checkout-id>`)
@@ -160,7 +160,7 @@ Override config location with `PI_POSTBOX_CONFIG_PATH` or `PI_POSTBOX_CONFIG_DIR
160160

161161
For local self-healing, each profile publishes only `<profile-state-dir>/active-local/server.json`. The extension validates that record against `/healthz` profile, instance, URL, protocol, and build identity. Health reports the package version separately from the protocol version, while the default build id fingerprints the loaded runtime bytes. It never orders or falls back across profiles. A global production loopback `serverUrl` is therefore invisible to a checkout development profile, while `PI_POSTBOX_URL` remains an intentional escape hatch.
162162

163-
`npm run dev` derives the checkout identity, selects independent backend/UI ports, persists them in the checkout profile's `dev-ports.json` for safe reuse across restarts, uses its own database and metadata, and never stops production. It relies on the server's content-specific build fingerprint instead of assigning one static build id to the checkout. It exposes the development API through Tailscale Serve when available and non-conflicting, using the API's separate port so the production mapping remains untouched. Set `PI_POSTBOX_TAILSCALE=off` to disable this exposure. Separate clones and worktrees can run concurrently. The dashboard title and persistent accessible `Development server` badge come from authoritative `/healthz` profile state.
163+
`npm run dev` derives the checkout identity, binds its API to the canonical development port `45795` (or an explicit `PI_POSTBOX_PORT`), selects and persists a separate Vite/HMR port in the checkout profile's `dev-ports.json`, uses its own database and metadata, and never stops production. If the API port is occupied, startup fails instead of changing the API URL; the Vite port may be safely reselected and persisted. The launcher relies on the server's content-specific build fingerprint instead of assigning one static build id to the checkout. It exposes the development API through Tailscale Serve when available and non-conflicting, using the API's separate port so the production mapping remains untouched. Set `PI_POSTBOX_TAILSCALE=off` to disable this exposure. The canonical API port is shared intentionally, so separate clones/worktrees need explicit distinct `PI_POSTBOX_PORT` values if run concurrently. The dashboard title and persistent accessible `Development server` badge come from authoritative `/healthz` profile state.
164164

165165
Package-local autostart is enabled by default for `write_question` and the user-only `/postbox` dashboard command. Set `PI_POSTBOX_AUTOSTART=off` to disable spawning a bundled server. Set `PI_POSTBOX_AUTOSTART_TIMEOUT_MS` to change the recovery wait; the default is 10 seconds (`10000` ms).
166166

apps/android/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,30 @@ https://postbox.your-tailnet.ts.net/
5151

5252
The app verifies the server with `GET /healthz` before saving the URL. Keep the existing Tailnet-private trust model: do not expose the Postbox server publicly for this prototype.
5353

54-
Android 0.4.2 (build 6) supports Postbox protocol 0.1.9 exactly. The app displays this identity before and after connection, rechecks saved endpoints, and blocks state, Answer, cancel, refresh, Question Chat, and push registration when the active endpoint reports missing or different protocol evidence. Regenerate the shared contract with `npm run generate:android-protocol-contract`; CI uses `npm run check:android-protocol-contract` plus Android unit/lint/assembly gates to reject drift.
54+
Android 0.4.3 (build 7) supports Postbox protocol 0.1.9 exactly. The app displays this identity before and after connection, rechecks saved endpoints, and blocks state, Answer, cancel, refresh, Question Chat, and push registration when the active endpoint reports missing or different protocol evidence. Regenerate the shared contract with `npm run generate:android-protocol-contract`; CI uses `npm run check:android-protocol-contract` plus Android unit/lint/assembly gates to reject drift.
5555

5656
## Emulator localhost fallback
5757

5858
Android emulators cannot reach the host machine at `localhost`. If the Postbox server is running on the development machine and you are testing in an emulator, use `10.0.2.2` as the host, for example:
5959

6060
```text
61-
http://10.0.2.2:32187/
61+
http://10.0.2.2:45795/
6262
```
6363

6464
Prefer the Tailnet HTTPS URL for real-device evidence; use the emulator fallback only for local development.
6565

6666
## Notifications and FCM push
6767

68-
The native app does not reuse browser Web Push subscriptions. Two notification paths share the same notifier and per-request notification ids, so a question never shows up twice:
68+
The native app does not reuse browser Web Push subscriptions. Two notification paths share the same notifier and per-request notification ids, so the same question does not produce duplicate per-question notifications:
6969

7070
1. **In-app observation**: while the connected question screen is active, fetched/SSE state snapshots are observed and newly seen pending request ids post one Android local notification.
71-
2. **FCM push (app closed or backgrounded)**: when Firebase is configured, the app registers its FCM token with the verified Postbox server, and the server pushes new pending questions as data-only FCM messages that `PostboxFirebaseMessagingService` renders through the same notifier.
71+
2. **FCM push (app closed or backgrounded)**: when Firebase is configured, the app registers its FCM token with the verified Postbox server, and the server pushes new pending questions as data-only FCM messages that `PostboxFirebaseMessagingService` renders through the same notifier. Its best-effort state prefetch also reconciles the full pending queue.
7272

73-
The notification content is privacy-preserving (it does not include the question prompt); tapping it reopens the app and selects the relevant question if that request is still present in the latest observed state.
73+
In addition to per-question notifications, every authoritative pending snapshot maintains one quiet, ongoing summary notification for the complete Android queue. The summary carries the pending count with Android's standard `setNumber` metadata and drives the launcher app-icon badge when the launcher supports notification badges. Launchers and OEMs control the presentation: some display the number and others display only a dot. Resolving the final pending question removes the summary and badge source.
7474

75-
On Android 13+, the app requests `POST_NOTIFICATIONS` and gates posting on the runtime permission. If permission is denied, notifications are disabled but the question workflow remains usable: loading, viewing, answering, and cancelling questions are not blocked.
75+
All notification content is privacy-preserving and omits Question prompts. Tapping a per-question notification reopens the app and selects that Question if it is still present in the latest observed state; tapping the summary opens the app.
76+
77+
On Android 13+, the app requests `POST_NOTIFICATIONS` and gates posting on the runtime permission. If permission is denied—or launcher/channel badges are disabled—the notification-backed launcher badge is unavailable, but the question workflow remains usable: loading, viewing, answering, and cancelling Questions are not blocked.
7678

7779
### Firebase setup
7880

apps/android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId = "dev.pi.postbox"
1616
minSdk = 26
1717
targetSdk = 36
18-
versionCode = 6
19-
versionName = "0.4.2"
18+
versionCode = 7
19+
versionName = "0.4.3"
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222
}

apps/android/app/src/main/java/dev/pi/postbox/notification/AndroidPendingQuestionNotifier.kt

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class AndroidPendingQuestionNotifier(
6060
.setContentText(PRIVATE_NOTIFICATION_TEXT)
6161
.setStyle(Notification.BigTextStyle().bigText(PRIVATE_NOTIFICATION_TEXT))
6262
.setContentIntent(notification.toPendingIntent())
63+
.setGroup(PENDING_QUESTIONS_GROUP_KEY)
6364
.setAutoCancel(true)
6465
.setShowWhen(true)
6566
.build()
@@ -109,7 +110,7 @@ class AndroidPendingQuestionNotifier(
109110
}
110111
}
111112

112-
/** Cancel app-owned notifications whose questions are no longer in the pending snapshot. */
113+
/** Reconcile per-Question notifications and the launcher badge against the full pending queue. */
113114
fun reconcilePendingRequests(pendingRequestIds: Set<String>) {
114115
val pendingNotificationIds = pendingRequestIds.mapTo(hashSetOf()) { it.hashCode() }
115116
try {
@@ -125,6 +126,39 @@ class AndroidPendingQuestionNotifier(
125126
} catch (_: SecurityException) {
126127
// Notification access can change while the app is running; reconciliation is best-effort.
127128
}
129+
reconcilePendingSummary(pendingRequestIds.size)
130+
}
131+
132+
@SuppressLint("MissingPermission")
133+
private fun reconcilePendingSummary(pendingCount: Int) {
134+
if (pendingCount == 0) {
135+
notificationManager.cancel(PENDING_SUMMARY_NOTIFICATION_ID)
136+
return
137+
}
138+
if (!permissionController.currentState().toAvailability().canPostNotifications) return
139+
140+
ensureSummaryChannel()
141+
val label = if (pendingCount == 1) "1 Postbox question waiting" else "$pendingCount Postbox questions waiting"
142+
try {
143+
notificationManager.notify(
144+
PENDING_SUMMARY_NOTIFICATION_ID,
145+
Notification.Builder(context, PENDING_SUMMARY_CHANNEL_ID)
146+
.setSmallIcon(R.drawable.ic_postbox_notification)
147+
.setContentTitle(label)
148+
.setContentText(PRIVATE_NOTIFICATION_TEXT)
149+
.setStyle(Notification.BigTextStyle().bigText(PRIVATE_NOTIFICATION_TEXT))
150+
.setContentIntent(pendingSummaryIntent())
151+
.setGroup(PENDING_QUESTIONS_GROUP_KEY)
152+
.setGroupSummary(true)
153+
.setNumber(pendingCount)
154+
.setOngoing(true)
155+
.setOnlyAlertOnce(true)
156+
.setShowWhen(false)
157+
.build()
158+
)
159+
} catch (_: SecurityException) {
160+
// Permission can be revoked between the preflight check and posting.
161+
}
128162
}
129163

130164
private fun ensureChannel() {
@@ -138,10 +172,40 @@ class AndroidPendingQuestionNotifier(
138172
NotificationManager.IMPORTANCE_DEFAULT
139173
).apply {
140174
description = CHANNEL_DESCRIPTION
175+
setShowBadge(true)
176+
}
177+
)
178+
}
179+
180+
private fun ensureSummaryChannel() {
181+
val existing = notificationManager.getNotificationChannel(PENDING_SUMMARY_CHANNEL_ID)
182+
if (existing != null) return
183+
184+
notificationManager.createNotificationChannel(
185+
NotificationChannel(
186+
PENDING_SUMMARY_CHANNEL_ID,
187+
PENDING_SUMMARY_CHANNEL_NAME,
188+
NotificationManager.IMPORTANCE_LOW
189+
).apply {
190+
description = PENDING_SUMMARY_CHANNEL_DESCRIPTION
191+
setShowBadge(true)
141192
}
142193
)
143194
}
144195

196+
private fun pendingSummaryIntent(): PendingIntent {
197+
val intent = Intent(context, MainActivity::class.java).apply {
198+
action = Intent.ACTION_MAIN
199+
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
200+
}
201+
return PendingIntent.getActivity(
202+
context,
203+
PENDING_SUMMARY_NOTIFICATION_ID,
204+
intent,
205+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
206+
)
207+
}
208+
145209
private fun PendingQuestionNotification.toPendingIntent(): PendingIntent {
146210
val intent = Intent(context, MainActivity::class.java).apply {
147211
action = tapTarget.intentAction
@@ -163,11 +227,16 @@ class AndroidPendingQuestionNotifier(
163227
companion object {
164228
const val CHANNEL_ID: String = "pending-postbox-questions"
165229
const val CHANNEL_NAME: String = "Postbox questions"
166-
const val CHANNEL_DESCRIPTION: String = "Local notifications for newly observed pending Postbox questions while the app is active."
230+
const val CHANNEL_DESCRIPTION: String = "Notifications for newly observed pending Postbox questions."
231+
const val PENDING_SUMMARY_CHANNEL_ID: String = "pending-postbox-question-count"
232+
const val PENDING_SUMMARY_CHANNEL_NAME: String = "Postbox question count"
233+
const val PENDING_SUMMARY_CHANNEL_DESCRIPTION: String = "Quiet pending-question total used for the launcher badge."
167234
const val PRIVATE_NOTIFICATION_TEXT: String = "Open Postbox to review and answer."
168235
const val EXTRA_REQUEST_ID: String = "dev.pi.postbox.extra.REQUEST_ID"
169236
const val ACTION_OPEN_PROTOCOL_MISMATCH: String = "dev.pi.postbox.OPEN_PROTOCOL_MISMATCH"
170237
const val PROTOCOL_MISMATCH_NOTIFICATION_ID: Int = 0x50524f54
238+
const val PENDING_SUMMARY_NOTIFICATION_ID: Int = 0x50424f58
239+
const val PENDING_QUESTIONS_GROUP_KEY: String = "dev.pi.postbox.PENDING_QUESTIONS"
171240
}
172241
}
173242

@@ -177,6 +246,7 @@ internal fun shouldCancelDuringPendingReconciliation(
177246
pendingNotificationIds: Set<Int>
178247
): Boolean = channelId == AndroidPendingQuestionNotifier.CHANNEL_ID &&
179248
notificationId != AndroidPendingQuestionNotifier.PROTOCOL_MISMATCH_NOTIFICATION_ID &&
249+
notificationId != AndroidPendingQuestionNotifier.PENDING_SUMMARY_NOTIFICATION_ID &&
180250
notificationId !in pendingNotificationIds
181251

182252
fun Intent.postboxNotificationRequestId(): String? {

0 commit comments

Comments
 (0)