Skip to content

Commit 2d0cfa8

Browse files
authored
Merge pull request #105 from dasomji/feat/issues-97-104
Harden Postbox protocol and recovery flows
2 parents e515d98 + 017f71d commit 2d0cfa8

60 files changed

Lines changed: 1122 additions & 197 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
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.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.
11+
Version 0.2.14 coordinates protocol 0.1.10 with Android 0.4.7 (build 11), distinguishes transport failures from protocol mismatches, opens cached saved-server workflows offline, restores stamped Question Chat transport state, makes Answer auto-wake recovery queue-aware, preserves valid config fields, reconciles resolved-push badges offline and idempotently (including after process restarts and opened child notifications), handles stale Question revisions as refreshable conflicts, and derives Android contract inputs from their sources of truth.
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

apps/android/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ 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.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.
54+
Android 0.4.7 (build 11) supports Postbox protocol 0.1.10 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

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 = 7
19-
versionName = "0.4.3"
18+
versionCode = 11
19+
versionName = "0.4.7"
2020

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

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

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.content.Intent
1111
import android.content.pm.PackageManager
1212
import android.net.Uri
1313
import android.os.Build
14+
import android.os.Bundle
1415
import androidx.core.content.ContextCompat
1516
import java.lang.SecurityException
1617
import dev.pi.postbox.MainActivity
@@ -126,11 +127,56 @@ class AndroidPendingQuestionNotifier(
126127
} catch (_: SecurityException) {
127128
// Notification access can change while the app is running; reconciliation is best-effort.
128129
}
129-
reconcilePendingSummary(pendingRequestIds.size)
130+
reconcilePendingSummary(pendingRequestIds)
131+
}
132+
133+
/** Recompute the badge after a resolved push when no authoritative cached queue is available. */
134+
fun reconcileResolvedPendingSummary(requestId: String) {
135+
val resolvedNotificationId = requestId.hashCode()
136+
val resolution = try {
137+
val activeNotifications = notificationManager.activeNotifications
138+
val summary = activeNotifications.firstOrNull { it.id == PENDING_SUMMARY_NOTIFICATION_ID }?.notification
139+
val authoritativePendingIds = summary?.extras
140+
?.getStringArray(EXTRA_PENDING_REQUEST_IDS)
141+
?.toSet()
142+
if (authoritativePendingIds != null) {
143+
reconcilePendingSummary(authoritativePendingIds - requestId)
144+
return
145+
}
146+
pendingSummaryAfterResolvedPush(
147+
summaryCount = summary?.number?.takeIf { it > 0 },
148+
activePendingNotificationIds = activeNotifications
149+
.filter { isPendingQuestionNotification(it.notification.channelId, it.id) }
150+
.mapTo(hashSetOf()) { it.id },
151+
resolvedNotificationId = resolvedNotificationId,
152+
resolvedRequestId = requestId,
153+
alreadyResolvedRequestIds = summary?.extras
154+
?.getStringArray(EXTRA_RESOLVED_REQUEST_IDS)
155+
?.toSet()
156+
.orEmpty()
157+
)
158+
} catch (_: SecurityException) {
159+
return
160+
}
161+
reconcilePendingSummary(
162+
pendingCount = resolution.pendingCount,
163+
resolvedRequestIds = resolution.resolvedRequestIds
164+
)
165+
}
166+
167+
private fun reconcilePendingSummary(pendingRequestIds: Set<String>) {
168+
reconcilePendingSummary(
169+
pendingCount = pendingRequestIds.size,
170+
pendingRequestIds = pendingRequestIds
171+
)
130172
}
131173

132174
@SuppressLint("MissingPermission")
133-
private fun reconcilePendingSummary(pendingCount: Int) {
175+
private fun reconcilePendingSummary(
176+
pendingCount: Int,
177+
pendingRequestIds: Set<String>? = null,
178+
resolvedRequestIds: Set<String> = emptySet()
179+
) {
134180
if (pendingCount == 0) {
135181
notificationManager.cancel(PENDING_SUMMARY_NOTIFICATION_ID)
136182
return
@@ -151,6 +197,12 @@ class AndroidPendingQuestionNotifier(
151197
.setGroup(PENDING_QUESTIONS_GROUP_KEY)
152198
.setGroupSummary(true)
153199
.setNumber(pendingCount)
200+
.setExtras(Bundle().apply {
201+
pendingRequestIds?.let { putStringArray(EXTRA_PENDING_REQUEST_IDS, it.sorted().toTypedArray()) }
202+
if (resolvedRequestIds.isNotEmpty()) {
203+
putStringArray(EXTRA_RESOLVED_REQUEST_IDS, resolvedRequestIds.sorted().toTypedArray())
204+
}
205+
})
154206
.setOngoing(true)
155207
.setOnlyAlertOnce(true)
156208
.setShowWhen(false)
@@ -233,6 +285,8 @@ class AndroidPendingQuestionNotifier(
233285
const val PENDING_SUMMARY_CHANNEL_DESCRIPTION: String = "Quiet pending-question total used for the launcher badge."
234286
const val PRIVATE_NOTIFICATION_TEXT: String = "Open Postbox to review and answer."
235287
const val EXTRA_REQUEST_ID: String = "dev.pi.postbox.extra.REQUEST_ID"
288+
internal const val EXTRA_PENDING_REQUEST_IDS: String = "dev.pi.postbox.extra.PENDING_REQUEST_IDS"
289+
internal const val EXTRA_RESOLVED_REQUEST_IDS: String = "dev.pi.postbox.extra.RESOLVED_REQUEST_IDS"
236290
const val ACTION_OPEN_PROTOCOL_MISMATCH: String = "dev.pi.postbox.OPEN_PROTOCOL_MISMATCH"
237291
const val PROTOCOL_MISMATCH_NOTIFICATION_ID: Int = 0x50524f54
238292
const val PENDING_SUMMARY_NOTIFICATION_ID: Int = 0x50424f58
@@ -244,11 +298,36 @@ internal fun shouldCancelDuringPendingReconciliation(
244298
channelId: String?,
245299
notificationId: Int,
246300
pendingNotificationIds: Set<Int>
247-
): Boolean = channelId == AndroidPendingQuestionNotifier.CHANNEL_ID &&
248-
notificationId != AndroidPendingQuestionNotifier.PROTOCOL_MISMATCH_NOTIFICATION_ID &&
249-
notificationId != AndroidPendingQuestionNotifier.PENDING_SUMMARY_NOTIFICATION_ID &&
301+
): Boolean = isPendingQuestionNotification(channelId, notificationId) &&
250302
notificationId !in pendingNotificationIds
251303

304+
internal fun isPendingQuestionNotification(channelId: String?, notificationId: Int): Boolean =
305+
channelId == AndroidPendingQuestionNotifier.CHANNEL_ID &&
306+
notificationId != AndroidPendingQuestionNotifier.PROTOCOL_MISMATCH_NOTIFICATION_ID &&
307+
notificationId != AndroidPendingQuestionNotifier.PENDING_SUMMARY_NOTIFICATION_ID
308+
309+
internal data class PendingSummaryResolution(
310+
val pendingCount: Int,
311+
val resolvedRequestIds: Set<String>
312+
)
313+
314+
internal fun pendingSummaryAfterResolvedPush(
315+
summaryCount: Int?,
316+
activePendingNotificationIds: Set<Int>,
317+
resolvedNotificationId: Int,
318+
resolvedRequestId: String,
319+
alreadyResolvedRequestIds: Set<String>
320+
): PendingSummaryResolution {
321+
val currentCount = summaryCount ?: activePendingNotificationIds.count { it != resolvedNotificationId }
322+
if (resolvedRequestId in alreadyResolvedRequestIds) {
323+
return PendingSummaryResolution(currentCount, alreadyResolvedRequestIds)
324+
}
325+
return PendingSummaryResolution(
326+
pendingCount = summaryCount?.let { (it - 1).coerceAtLeast(0) } ?: currentCount,
327+
resolvedRequestIds = alreadyResolvedRequestIds + resolvedRequestId
328+
)
329+
}
330+
252331
fun Intent.postboxNotificationRequestId(): String? {
253332
if (action != NotificationTapTarget.ACTION_OPEN_QUESTION) return null
254333
return getStringExtra(AndroidPendingQuestionNotifier.EXTRA_REQUEST_ID)

apps/android/app/src/main/java/dev/pi/postbox/onboarding/PostboxHealthVerifier.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import java.util.concurrent.TimeUnit
1010
import kotlinx.serialization.Serializable
1111
import kotlinx.serialization.SerializationException
1212
import kotlinx.serialization.json.Json
13+
import kotlinx.serialization.json.decodeFromJsonElement
1314
import okhttp3.OkHttpClient
1415
import okhttp3.Request
1516
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -50,15 +51,18 @@ class OkHttpPostboxHealthVerifier(
5051
val health = try {
5152
compatibilityGate.decodeHttpResponse(
5253
rawMessage = body,
54+
statusCode = response.code,
5355
responseProtocolVersion = response.header("X-Postbox-Protocol-Version"),
5456
source = ProtocolMessageSource.HEALTH
5557
) {
56-
json.decodeFromString<PostboxHealthResponse>(it)
58+
json.decodeFromJsonElement<PostboxHealthResponse>(it)
5759
}
5860
} catch (exception: PostboxProtocolMismatchException) {
5961
return HealthVerificationResult.IncompatibleProtocol(exception.mismatch)
6062
} catch (_: SerializationException) {
6163
return HealthVerificationResult.Rejected(HealthRejectionReason.MALFORMED_HEALTH_RESPONSE)
64+
} catch (_: dev.pi.postbox.protocol.PostboxProtocolHttpException) {
65+
return HealthVerificationResult.Rejected(HealthRejectionReason.MALFORMED_HEALTH_RESPONSE)
6266
}
6367

6468
if (!response.isSuccessful) {

apps/android/app/src/main/java/dev/pi/postbox/onboarding/ServerOnboardingViewModel.kt

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,10 @@ class ServerOnboardingViewModel(
2222
val savedBaseUrl = store.loadVerifiedServerUrl()
2323
if (savedBaseUrl != null) {
2424
serverUrl = savedBaseUrl
25-
state = ServerOnboardingState.Verifying(baseUrl = savedBaseUrl)
26-
applyVerificationResultIfCurrent(
25+
state = ServerOnboardingState.Ready(baseUrl = savedBaseUrl)
26+
applySavedVerificationResultIfCurrent(
2727
generation = generation,
28-
normalizedBaseUrl = savedBaseUrl,
29-
warning = null,
28+
savedBaseUrl = savedBaseUrl,
3029
result = verifier.verify(savedBaseUrl)
3130
)
3231
}
@@ -36,14 +35,13 @@ class ServerOnboardingViewModel(
3635
val generation = ++verificationGeneration
3736
val savedBaseUrl = store.loadVerifiedServerUrl() ?: return
3837
serverUrl = savedBaseUrl
39-
state = ServerOnboardingState.Verifying(baseUrl = savedBaseUrl)
38+
state = ServerOnboardingState.Ready(baseUrl = savedBaseUrl)
4039
val result = withContext(Dispatchers.IO) {
4140
verifier.verify(savedBaseUrl)
4241
}
43-
applyVerificationResultIfCurrent(
42+
applySavedVerificationResultIfCurrent(
4443
generation = generation,
45-
normalizedBaseUrl = savedBaseUrl,
46-
warning = null,
44+
savedBaseUrl = savedBaseUrl,
4745
result = result
4846
)
4947
}
@@ -117,6 +115,35 @@ class ServerOnboardingViewModel(
117115
handleVerificationResult(normalizedBaseUrl, warning, result)
118116
}
119117

118+
private fun applySavedVerificationResultIfCurrent(
119+
generation: Long,
120+
savedBaseUrl: String,
121+
result: HealthVerificationResult
122+
) {
123+
if (generation != verificationGeneration) return
124+
when (result) {
125+
is HealthVerificationResult.IncompatibleProtocol -> {
126+
state = ServerOnboardingState.IncompatibleProtocol(
127+
baseUrl = savedBaseUrl,
128+
mismatch = result.mismatch
129+
)
130+
}
131+
is HealthVerificationResult.Valid -> {
132+
state = ServerOnboardingState.Ready(
133+
baseUrl = savedBaseUrl,
134+
health = VerifiedPostboxHealth(
135+
service = result.service,
136+
version = result.version,
137+
protocolVersion = result.protocolVersion,
138+
buildId = result.buildId
139+
)
140+
)
141+
}
142+
is HealthVerificationResult.Unreachable,
143+
is HealthVerificationResult.Rejected -> Unit
144+
}
145+
}
146+
120147
private fun handleVerificationResult(
121148
normalizedBaseUrl: String,
122149
warning: ServerUrlWarning?,

apps/android/app/src/main/java/dev/pi/postbox/protocol/GeneratedPostboxProtocolContract.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package dev.pi.postbox.protocol
22

33
/** Generated by scripts/generate-android-protocol-contract.mjs. Do not edit. */
44
object GeneratedPostboxProtocolContract {
5-
const val SUPPORTED_PROTOCOL_VERSION: String = "0.1.9"
6-
const val CONTRACT_FINGERPRINT: String = "ad5e1496494a3a09bdc06fc33a078468b1d8d721600bfff55b1ca733080d7c07"
5+
const val SUPPORTED_PROTOCOL_VERSION: String = "0.1.10"
6+
const val CONTRACT_FINGERPRINT: String = "714b443f32821ea63c645ea241207a87dbfeaf1e261639305f0f50b70b8109c6"
77
val SEMANTIC_STATES: List<String> = listOf("working", "blocked", "waiting_for_postbox", "idle", "unknown")
88
val PRESENCE_STATES: List<String> = listOf("live", "stale", "offline")
99
val ASK_MODES: List<String> = listOf("single", "multi")

apps/android/app/src/main/java/dev/pi/postbox/protocol/PostboxProtocol.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
55
import kotlinx.serialization.SerializationException
66
import kotlinx.serialization.json.Json
7+
import kotlinx.serialization.json.JsonObject
78

89
/**
910
* Synthetic answer value the server accepts alongside a question's own options,
@@ -19,6 +20,8 @@ object PostboxProtocolJson {
1920

2021
fun decodeStateSnapshot(value: String): StateSnapshot = json.decodeFromString(value)
2122

23+
fun decodeStateSnapshot(value: JsonObject): StateSnapshot = json.decodeFromJsonElement(StateSnapshot.serializer(), value)
24+
2225
fun encodeAnswerPayload(payload: AskAnswerPayload): String = json.encodeToString(AskAnswerPayload.serializer(), payload)
2326

2427
fun encodeCancelPayload(payload: AskCancelPayload): String = json.encodeToString(AskCancelPayload.serializer(), payload)
@@ -248,5 +251,11 @@ internal data class PostboxErrorResponse(
248251
} catch (_: SerializationException) {
249252
null
250253
}
254+
255+
fun parse(body: JsonObject): PostboxErrorResponse? = try {
256+
PostboxProtocolJson.json.decodeFromJsonElement(serializer(), body)
257+
} catch (_: SerializationException) {
258+
null
259+
}
251260
}
252261
}

0 commit comments

Comments
 (0)