From 31cc99d7352a76245f54dbfdeaefa009faf9c1e0 Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:57:42 +0530 Subject: [PATCH] feat: release hosted mobile picker v0.2.0 --- .github/workflows/publish-maven-central.yml | 21 +++ CHANGELOG.md | 13 ++ README.md | 27 ++- docs/bridge.md | 32 ++-- gradle.properties | 2 +- sample/build.gradle.kts | 2 +- .../io/seatlayer/sample/MainActivity.kt | 76 ++++----- .../kotlin/io/seatlayer/android/Models.kt | 134 +++++++++++++++ .../android/SeatLayerConfiguration.kt | 63 ++++++- .../seatlayer/android/SeatLayerController.kt | 159 ++++++++++++++++++ .../io/seatlayer/android/SeatLayerView.kt | 21 +-- .../kotlin/io/seatlayer/android/ModelsTest.kt | 57 +++++++ 12 files changed, 521 insertions(+), 86 deletions(-) diff --git a/.github/workflows/publish-maven-central.yml b/.github/workflows/publish-maven-central.yml index 2af2fcf..bfcec50 100644 --- a/.github/workflows/publish-maven-central.yml +++ b/.github/workflows/publish-maven-central.yml @@ -2,6 +2,11 @@ name: Publish to Maven Central on: workflow_dispatch: + inputs: + version: + description: Exact SDK version whose vX.Y.Z tag should be published + required: true + type: string permissions: contents: read @@ -13,11 +18,27 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v7 + with: + ref: refs/tags/v${{ inputs.version }} - uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - uses: gradle/actions/setup-gradle@v6 + - name: Verify tag, package version, and hosted runtime + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + test "$(git describe --tags --exact-match)" = "v$RELEASE_VERSION" + test "$(sed -n 's/^VERSION_NAME=//p' gradle.properties)" = "$RELEASE_VERSION" + runtime="$(sed -n 's/.*SEATLAYER_HOSTED_WEB_VERSION: String = "\(.*\)".*/\1/p' seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt)" + metadata="$(curl --fail --silent --show-error "https://cdn.seatlayer.io/seatlayer-js@$runtime/release.json")" + test "$(printf '%s' "$metadata" | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')" = "$runtime" + test "$(printf '%s' "$metadata" | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["promotable"]).lower())')" = true + curl --fail --silent --show-error --output /dev/null \ + "https://cdn.seatlayer.io/seatlayer-js@$runtime/mobile.html" + - name: Validate release source and consumer sample + run: ./gradlew validate - name: Publish and release run: ./gradlew :seatlayer:publishAndReleaseToMavenCentral env: diff --git a/CHANGELOG.md b/CHANGELOG.md index a706c07..ccc8935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.2.0 + +- Uses pinned `seatlayer-js@0.66.0/mobile.html` at `https://cdn.seatlayer.io`. + Buyer access must be minted for that exact allowed origin. +- Separates the hosted runtime version (`0.66.0`) from the retained verified + fixture version (`0.59.0`) while preserving the old constant as deprecated. +- Adds renewable private buyer access, programmatic selection/category + controls, exact-count validators, typed validity/access events, and + fail-closed capability negotiation. +- Locks both bridge messages and top-level navigation to the hosted origin/page. +- Restores the runnable consumer sample and compiles it against the new + selection-policy API as part of the release validation task. + ## 0.1.3 - Updated the vendored buyer runtime to `seatlayer-js@0.59.0` (sha256 diff --git a/README.md b/README.md index 5f7f412..c947ba4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ seating map in an Android app. [AI toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit) · [Web and React SDKs](https://github.com/seatlayer/seatlayer-sdk) -> **Public preview:** `0.1.2` is available from JitPack while the permanent +> **Public preview:** `0.2.0` is available from JitPack while the permanent > `io.seatlayer:seatlayer-android` Maven Central namespace is completed. ## What is included @@ -24,7 +24,7 @@ seating map in an Android app. - Typed Kotlin models, `StateFlow` readiness, and `SharedFlow` events. - An origin-restricted AndroidX WebKit bridge with no unrestricted `addJavascriptInterface`. -- A pinned, checksummed SeatLayer Web SDK bundle for deterministic builds. +- A pinned immutable `seatlayer-js@0.66.0/mobile.html` production document. - A runnable sample app and protocol/unit tests. ## Requirements @@ -58,7 +58,7 @@ Add the SDK: // app/build.gradle.kts dependencies { implementation( - "com.github.seatlayer:seatlayer-android:v0.1.2", + "com.github.seatlayer:seatlayer-android:v0.2.0", ) } ``` @@ -152,13 +152,16 @@ val best = controller.bestAvailable(quantity = 4) val ga = controller.holdGeneralAdmission(areaId = "floor", quantity = 2) controller.setSeatTier(seatId = "A-12", tierId = "adult") +controller.selectObjects(listOf("A-12", "A-13")) +controller.setSelectableObjects(listOf("A-12", "A-13", "A-14")) +val validity = controller.getSelectionValidity() controller.setFloor("balcony") controller.setViewMode(SeatLayerViewMode.Isometric) controller.setColorblindSafe(true) controller.zoomToFit() ``` -Before using a newly introduced command with an older bundled Web SDK, inspect +Before using a newly introduced command with an older Web runtime, inspect the negotiated capability: ```kotlin @@ -180,6 +183,9 @@ the full command surface. | `apiBase` | Optional SeatLayer API endpoint override. | | `publicKey` | Optional public SDK key. Never provide a secret key. | | `maxSelection` | Maximum buyer selection. | +| `selectedObjects`, `selectableObjects` | Initial selection and selectable allow-list. | +| `numberOfPlacesToSelect`, `selectionValidators` | Exact-count and adjacency/orphan rules. | +| `buyerAccessToken`, `buyerAccessTokenProvider` | One-shot or renewable private buyer access. | | `locale`, `messages` | Locale and UI message overrides. | | `currency` | Buyer-facing currency. | | `colorblindSafe` | Accessible palette preference. | @@ -190,10 +196,13 @@ the full command surface. ## Security model -The SDK loads only app-packaged content from -`https://appassets.androidplatform.net`. Native communication uses AndroidX -WebKit's origin-restricted message listener. File/content access, mixed content, -popups, external navigation, and third-party cookies are disabled. +The SDK loads only the exact immutable +`https://cdn.seatlayer.io/seatlayer-js@0.66.0/mobile.html` page. Native +communication uses AndroidX WebKit's origin-restricted message listener for +`https://cdn.seatlayer.io`. File/content access, mixed content, popups, external +navigation, and third-party cookies are disabled. Private buyer sessions must +be minted by your backend for that exact allowed origin; bearer values remain +in memory and are never placed in URLs or events. The bridge is still a client boundary. Treat every event as untrusted input, authorize inventory changes on your server, and finalize bookings server-side. @@ -206,7 +215,7 @@ authorize inventory changes on your server, and finalize bookings server-side. ``` `validate` runs unit tests, Android lint, release/sample builds, Maven metadata -generation, and the vendored Web SDK checksum. +generation, and the retained legacy fixture checksum. ## Package status diff --git a/docs/bridge.md b/docs/bridge.md index a8b8d3b..ca26bde 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -1,8 +1,8 @@ # Android bridge reference -The Android SDK and its vendored SeatLayer Web SDK communicate through protocol -revision 1. Both sides negotiate their supported range before the chart becomes -ready. +The Android SDK and the immutable hosted SeatLayer mobile runtime communicate +through protocol revision 1. Both sides negotiate their supported range and +required capabilities before the chart becomes ready. ## Lifecycle @@ -37,6 +37,9 @@ All commands are suspending and throw `SeatLayerException` on failure. | Best available | `bestAvailable` | | General admission | `holdGeneralAdmission`, `getGeneralAdmissionAreas` | | Seat pricing tier | `setSeatTier` | +| Selection controls | `selectObjects`, `deselectObjects`, `clearSelection`, `selectCategories`, `deselectCategories` | +| Selection policy | `setSelectableObjects`, `setMaxSelection`, `getSelectionValidity` | +| Private buyer access | `refreshAccess` | | Current state | `getSelection`, `getCurrentHold` | | Floors | `getFloors`, `setFloor` | | Accessibility | `setColorblindSafe` | @@ -51,6 +54,8 @@ timed-out or replaced chart is discarded. The typed event surface includes: - `SelectionChanged` +- `SelectionValidityChanged`, `SelectionValid`, `SelectionInvalid`, and `SelectionLimitReached` +- `BuyerAccessExpired`, `BuyerAccessUnavailable`, and `SelectedObjectsUnavailable` - `HoldChanged`, `HoldRestored`, and `HoldExpired` - `GeneralAdmissionClicked` - `SeatHovered` and `DeckTapped` @@ -73,20 +78,25 @@ refresh selection state and let the buyer choose again. ## Bridge security -The packaged HTML is served by `WebViewAssetLoader` from the app-owned -`https://appassets.androidplatform.net` origin. The native listener accepts only -main-frame messages from that exact origin. +The exact `seatlayer-js@0.66.0/mobile.html` page is served from +`https://cdn.seatlayer.io`. The native listener accepts only main-frame messages +from that exact origin, and top-level navigation is locked to the exact page. +This canonical HTTPS origin is what integrator backends must place in private +buyer-session `allowedOrigins`. The implementation deliberately avoids the legacy unrestricted -`addJavascriptInterface` bridge. External page navigation and mixed content are -blocked, and no remote page receives the native channel. +`addJavascriptInterface` bridge. File/content access, external navigation, and +mixed content are blocked. Bearers remain in memory and never enter page URLs, +events, or error diagnostics. ## Version pairing | Component | Version | | --- | --- | -| Android SDK | `0.1.2` | -| Vendored SeatLayer Web SDK | `0.59.0` | +| Android SDK | `0.2.0` | +| Hosted SeatLayer mobile runtime | `0.66.0` | +| Explicit legacy fixture | `0.59.0` | | Bridge protocol | `1` | -The Web SDK asset is pinned by SHA-256 and checked by `./gradlew validate`. +The runtime URL is immutable and version-pinned. Its final artifact checksum is +recorded by the web-runtime release pipeline before the app SDK is published. diff --git a/gradle.properties b/gradle.properties index d58b6da..0ccbfec 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,5 +6,5 @@ android.nonTransitiveRClass=true android.defaults.buildfeatures.resvalues=false android.defaults.buildfeatures.shaders=false GROUP=io.seatlayer -VERSION_NAME=0.1.3 +VERSION_NAME=0.2.0 POM_ARTIFACT_ID=seatlayer-android diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 72e639d..f5c8d47 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -11,7 +11,7 @@ android { minSdk = 24 targetSdk = 36 versionCode = 1 - versionName = "0.1.0" + versionName = "0.2.0" } } diff --git a/sample/src/main/kotlin/io/seatlayer/sample/MainActivity.kt b/sample/src/main/kotlin/io/seatlayer/sample/MainActivity.kt index b7f4e16..726d51a 100644 --- a/sample/src/main/kotlin/io/seatlayer/sample/MainActivity.kt +++ b/sample/src/main/kotlin/io/seatlayer/sample/MainActivity.kt @@ -1,15 +1,12 @@ package io.seatlayer.sample import android.app.Activity -import android.graphics.Color import android.os.Bundle -import android.view.Gravity -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.TextView +import android.util.Log import io.seatlayer.android.SeatLayerConfiguration import io.seatlayer.android.SeatLayerEvent import io.seatlayer.android.SeatLayerView +import io.seatlayer.android.SelectionValidator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -17,49 +14,27 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -class MainActivity : Activity() { +/** Minimal consumer app used by the release build and manual device smoke test. */ +public class MainActivity : Activity() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private lateinit var seatLayerView: SeatLayerView - private lateinit var status: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val root = FrameLayout(this).apply { - setBackgroundColor(Color.rgb(248, 250, 252)) - } seatLayerView = SeatLayerView(this) - status = TextView(this).apply { - setTextColor(Color.rgb(51, 65, 85)) - setBackgroundColor(Color.argb(230, 255, 255, 255)) - setPadding(32, 20, 32, 20) - text = "Loading SeatLayer…" - } - root.addView( - seatLayerView, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - root.addView( - status, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ), - ) - setContentView(root) + setContentView(seatLayerView) scope.launch { seatLayerView.controller.events.collect { event -> - if (event is SeatLayerEvent.SelectionChanged) { - status.text = if (event.seats.isEmpty()) { - "Choose your seats" - } else { - event.seats.joinToString { it.buyerFacingLabel } - } + when (event) { + is SeatLayerEvent.SelectionChanged -> + Log.i(TAG, "selection=${event.seats.size}") + is SeatLayerEvent.SelectionValidityChanged -> + Log.i(TAG, "selection-valid=${event.validity.isValid}") + is SeatLayerEvent.Error -> + Log.e(TAG, "picker-error=${event.error.code}") + else -> Unit } } } @@ -68,21 +43,34 @@ class MainActivity : Activity() { runCatching { seatLayerView.load( SeatLayerConfiguration( - event = "ev_your_event_key", + event = intent.getStringExtra(EXTRA_EVENT) ?: DEFAULT_EVENT, currency = "USD", + maxSelection = 4, + numberOfPlacesToSelect = 2, + selectionValidators = listOf( + SelectionValidator.MinimumSelectedPlaces(2), + SelectionValidator.ConsecutiveSeats, + ), + hostInfo = mapOf("app" to "SeatLayerAndroidSample/0.2.0"), ), ) - }.onSuccess { - status.text = "SeatLayer ready · ${it.mode.raw}" - }.onFailure { - status.text = "Could not load SeatLayer: ${it.message}" + }.onSuccess { ready -> + Log.i(TAG, "ready protocol=${ready.protocolRevision} mode=${ready.mode.raw}") + }.onFailure { error -> + Log.e(TAG, "load failed", error) } } } override fun onDestroy() { - seatLayerView.destroy() + if (::seatLayerView.isInitialized) seatLayerView.destroy() scope.cancel() super.onDestroy() } + + private companion object { + const val TAG = "SeatLayerSample" + const val EXTRA_EVENT = "seatlayerEvent" + const val DEFAULT_EVENT = "ev_test_event" + } } diff --git a/seatlayer/src/main/kotlin/io/seatlayer/android/Models.kt b/seatlayer/src/main/kotlin/io/seatlayer/android/Models.kt index 03a52f5..4edfdea 100644 --- a/seatlayer/src/main/kotlin/io/seatlayer/android/Models.kt +++ b/seatlayer/src/main/kotlin/io/seatlayer/android/Models.kt @@ -30,6 +30,63 @@ public value class SeatLayerViewMode(public val raw: String) { } } +@JvmInline +public value class BuyerAccessRefreshReason(public val raw: String) { + public companion object { + public val Initial: BuyerAccessRefreshReason = BuyerAccessRefreshReason("initial") + public val Expiring: BuyerAccessRefreshReason = BuyerAccessRefreshReason("expiring") + public val Expired: BuyerAccessRefreshReason = BuyerAccessRefreshReason("expired") + public val Unauthorized: BuyerAccessRefreshReason = BuyerAccessRefreshReason("unauthorized") + public val Reconnect: BuyerAccessRefreshReason = BuyerAccessRefreshReason("reconnect") + public val Manual: BuyerAccessRefreshReason = BuyerAccessRefreshReason("manual") + } +} + +@JvmInline +public value class BuyerAccessUnavailableReason(public val raw: String) { + public companion object { + public val Revoked: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("revoked") + public val Paused: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("paused") + public val Invalid: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("invalid") + public val OriginMismatch: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("origin_mismatch") + public val EventMismatch: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("event_mismatch") + public val GroupMismatch: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("group_mismatch") + public val ModeMismatch: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("mode_mismatch") + public val ChannelDenied: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("channel_denied") + public val InvalidScope: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("invalid_scope") + public val ProviderFailed: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("provider_failed") + public val NoToken: BuyerAccessUnavailableReason = BuyerAccessUnavailableReason("no_token") + } +} + +@JvmInline +public value class SelectionViolation(public val raw: String) + +@JvmInline +public value class SelectedObjectUnavailableReason(public val raw: String) + +public data class BuyerAccessToken( + val token: String, + /** Epoch milliseconds. Omit to refresh reactively after server rejection. */ + val expiresAt: Double? = null, +) + +public data class BuyerAccessRequestContext( + val reason: BuyerAccessRefreshReason, +) + +public fun interface BuyerAccessTokenProvider { + public suspend fun provide(context: BuyerAccessRequestContext): BuyerAccessToken +} + +public sealed interface SelectionValidator { + public data class MinimumSelectedPlaces(val minimum: Int) : SelectionValidator { + init { require(minimum > 0) { "minimum must be positive" } } + } + public data object ConsecutiveSeats : SelectionValidator + public data object NoOrphanSeats : SelectionValidator +} + public data class CategoryTier( val id: String, val name: String, @@ -48,6 +105,34 @@ public data class SelectedSeat( public val buyerFacingLabel: String get() = displayLabel ?: label } +public data class SelectionValidity( + val isValid: Boolean, + val count: Int, + val required: Int, + val remaining: Int, + val seats: List, + val violations: List, +) + +public data class BuyerAccessExpiredEvent( + val reason: BuyerAccessRefreshReason, + val code: String?, + val refreshed: Boolean, +) + +public data class BuyerAccessUnavailableEvent( + val reason: BuyerAccessUnavailableReason, + val code: String?, + val status: Int?, + val retryable: Boolean, +) + +public data class SelectedObjectUnavailableEvent( + val labels: List, + val reason: SelectedObjectUnavailableReason, + val code: String?, +) + public data class HoldLineItem( val label: String, val objectId: String?, @@ -114,6 +199,15 @@ public data class BundleInfo( public sealed interface SeatLayerEvent { public data class SelectionChanged(val seats: List) : SeatLayerEvent + public data class SelectionValidityChanged(val validity: SelectionValidity) : SeatLayerEvent + public data class SelectionValid(val seats: List) : SeatLayerEvent + public data class SelectionInvalid(val validity: SelectionValidity) : SeatLayerEvent + public data class SelectionLimitReached(val maximum: Int) : SeatLayerEvent + public data class BuyerAccessExpired(val event: BuyerAccessExpiredEvent) : SeatLayerEvent + public data class BuyerAccessUnavailable(val event: BuyerAccessUnavailableEvent) : SeatLayerEvent + public data class SelectedObjectsUnavailable( + val event: SelectedObjectUnavailableEvent, + ) : SeatLayerEvent public data class HoldChanged(val hold: HoldResult) : SeatLayerEvent public data class HoldRestored(val hold: HoldResult) : SeatLayerEvent public data object HoldExpired : SeatLayerEvent @@ -163,6 +257,46 @@ internal fun decodeSelectedSeat(value: JsonElement): SelectedSeat? { ) } +internal fun decodeSelectionValidity(value: JsonElement?): SelectionValidity? { + val root = value as? JsonObject ?: return null + return SelectionValidity( + isValid = root.boolean("isValid") ?: return null, + count = root.int("count") ?: return null, + required = root.int("required") ?: return null, + remaining = root.int("remaining") ?: return null, + seats = root.array("seats").orEmpty().mapNotNull(::decodeSelectedSeat), + violations = root.stringList("violations").map(::SelectionViolation), + ) +} + +internal fun decodeBuyerAccessExpired(value: JsonElement?): BuyerAccessExpiredEvent? { + val root = value as? JsonObject ?: return null + return BuyerAccessExpiredEvent( + reason = BuyerAccessRefreshReason(root.string("reason") ?: return null), + code = root.string("code"), + refreshed = root.boolean("refreshed") ?: return null, + ) +} + +internal fun decodeBuyerAccessUnavailable(value: JsonElement?): BuyerAccessUnavailableEvent? { + val root = value as? JsonObject ?: return null + return BuyerAccessUnavailableEvent( + reason = BuyerAccessUnavailableReason(root.string("reason") ?: return null), + code = root.string("code"), + status = root.int("status"), + retryable = root.boolean("retryable") ?: return null, + ) +} + +internal fun decodeSelectedObjectsUnavailable(value: JsonElement?): SelectedObjectUnavailableEvent? { + val root = value as? JsonObject ?: return null + return SelectedObjectUnavailableEvent( + labels = root.stringList("labels"), + reason = SelectedObjectUnavailableReason(root.string("reason") ?: return null), + code = root.string("code"), + ) +} + internal fun decodeHold(value: JsonElement?): HoldResult? { val root = value as? JsonObject ?: return null val holdId = root.string("holdId") ?: return null diff --git a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt index 6c04f8d..4dfc96c 100644 --- a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt +++ b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt @@ -3,8 +3,16 @@ package io.seatlayer.android import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -public const val SEATLAYER_ANDROID_VERSION: String = "0.1.3" -public const val SEATLAYER_BUNDLED_WEB_VERSION: String = "0.59.0" +public const val SEATLAYER_ANDROID_VERSION: String = "0.2.0" +public const val SEATLAYER_HOSTED_WEB_VERSION: String = "0.66.0" +public const val SEATLAYER_LEGACY_FIXTURE_WEB_VERSION: String = "0.59.0" +@Deprecated( + message = "Production uses the hosted runtime; use SEATLAYER_HOSTED_WEB_VERSION.", + replaceWith = ReplaceWith("SEATLAYER_HOSTED_WEB_VERSION"), +) +public const val SEATLAYER_BUNDLED_WEB_VERSION: String = SEATLAYER_HOSTED_WEB_VERSION +public const val SEATLAYER_MOBILE_ORIGIN: String = "https://cdn.seatlayer.io" +public const val SEATLAYER_MOBILE_PAGE_URL: String = "$SEATLAYER_MOBILE_ORIGIN/seatlayer-js@$SEATLAYER_HOSTED_WEB_VERSION/mobile.html" public data class SeatLayerConfiguration( val event: String, @@ -17,6 +25,15 @@ public data class SeatLayerConfiguration( val colorblindSafe: Boolean? = null, val initialView: SeatLayerViewMode? = null, val showsWebSeatTooltip: Boolean = false, + /** One-shot private buyer bearer. Prefer [buyerAccessTokenProvider]. */ + val buyerAccessToken: BuyerAccessToken? = null, + /** Renews private buyer access in memory without rebuilding the view. */ + val buyerAccessTokenProvider: BuyerAccessTokenProvider? = null, + val selectedObjects: List? = null, + /** `null` means every otherwise eligible object remains selectable. */ + val selectableObjects: List? = null, + val numberOfPlacesToSelect: Int? = null, + val selectionValidators: List? = null, val commandTimeoutMillis: Long = 15_000, val handshakeTimeoutMillis: Long = 30_000, val hostInfo: Map = emptyMap(), @@ -26,6 +43,15 @@ public data class SeatLayerConfiguration( require(maxSelection == null || maxSelection > 0) { "maxSelection must be positive" } + require(numberOfPlacesToSelect == null || numberOfPlacesToSelect > 0) { + "numberOfPlacesToSelect must be positive" + } + require(buyerAccessToken == null || buyerAccessToken.token.isNotBlank()) { + "buyerAccessToken.token must not be blank" + } + require(buyerAccessToken?.expiresAt?.isFinite() != false) { + "buyerAccessToken.expiresAt must be finite" + } require(commandTimeoutMillis > 0) { "commandTimeoutMillis must be positive" } require(handshakeTimeoutMillis > 0) { "handshakeTimeoutMillis must be positive" } } @@ -53,7 +79,40 @@ public data class SeatLayerConfiguration( "currency" to jsonString(currency), "colorblindSafe" to jsonBoolean(colorblindSafe), "initialView" to jsonString(initialView?.raw), + "buyerAccessToken" to buyerAccessToken?.toJson(), + "nativeAccessProvider" to buyerAccessTokenProvider?.let { JsonPrimitive(true) }, + "selectedObjects" to selectedObjects?.let(::jsonStrings), + "selectableObjects" to selectableObjects?.let(::jsonStrings), + "numberOfPlacesToSelect" to jsonNumber(numberOfPlacesToSelect), + "selectionValidators" to selectionValidators?.let { + kotlinx.serialization.json.JsonArray(it.map(SelectionValidator::toJson)) + }, ), ) } + + internal val usesPrivateAccess: Boolean + get() = buyerAccessToken != null || buyerAccessTokenProvider != null + + internal val usesSelectionPolicy: Boolean + get() = selectedObjects != null || selectableObjects != null || + numberOfPlacesToSelect != null || selectionValidators != null +} + +private fun BuyerAccessToken.toJson(): JsonObject = jsonObject( + "token" to JsonPrimitive(token), + "expiresAt" to jsonNumber(expiresAt), +) + +private fun SelectionValidator.toJson(): JsonObject = when (this) { + is SelectionValidator.MinimumSelectedPlaces -> jsonObject( + "type" to JsonPrimitive("minimumSelectedPlaces"), + "minimum" to JsonPrimitive(minimum), + ) + SelectionValidator.ConsecutiveSeats -> jsonObject( + "type" to JsonPrimitive("consecutiveSeats"), + ) + SelectionValidator.NoOrphanSeats -> jsonObject( + "type" to JsonPrimitive("noOrphanSeats"), + ) } diff --git a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerController.kt b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerController.kt index efbb368..fed9e63 100644 --- a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerController.kt +++ b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerController.kt @@ -1,6 +1,11 @@ package io.seatlayer.android import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -8,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -19,6 +25,7 @@ public class SeatLayerController { private var configuration: SeatLayerConfiguration? = null private var readyDeferred: CompletableDeferred? = null private var destroyed = false + private var generationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val mutableReady = MutableStateFlow(null) private val mutableBundle = MutableStateFlow(null) @@ -37,6 +44,8 @@ public class SeatLayerController { ) { check(!destroyed) { "SeatLayerController was destroyed" } client?.close() + generationScope.cancel() + generationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) this.configuration = configuration mutableReady.value = null mutableBundle.value = null @@ -113,6 +122,30 @@ public class SeatLayerController { return } val config = configuration ?: return + if (config.usesPrivateAccess && !info.supportsCapability("native-access-provider")) { + finishFailure( + SeatLayerException.Incompatible( + ProtocolRange.Native, + info.protocolRange, + "The web bundle does not support private buyer access.", + ), + ) + return + } + if ( + config.usesSelectionPolicy && + (!info.supportsCapability("selection-controls") || + !info.supportsCapability("selection-validity")) + ) { + finishFailure( + SeatLayerException.Incompatible( + ProtocolRange.Native, + info.protocolRange, + "The web bundle does not support the configured selection policy.", + ), + ) + return + } client?.sendInit(config.initPayload()) } @@ -145,6 +178,36 @@ public class SeatLayerController { .mapNotNull(::decodeSelectedSeat) mutableEvents.tryEmit(SeatLayerEvent.SelectionChanged(seats)) } + "selection.validity.changed" -> decodeSelectionValidity( + (payload as? JsonObject)?.get("validity"), + )?.let { + mutableEvents.tryEmit(SeatLayerEvent.SelectionValidityChanged(it)) + } + "selection.valid" -> { + val seats = (payload as? JsonObject) + ?.array("seats") + .orEmpty() + .mapNotNull(::decodeSelectedSeat) + mutableEvents.tryEmit(SeatLayerEvent.SelectionValid(seats)) + } + "selection.invalid" -> decodeSelectionValidity( + (payload as? JsonObject)?.get("validity"), + )?.let { + mutableEvents.tryEmit(SeatLayerEvent.SelectionInvalid(it)) + } + "selection.limit" -> (payload as? JsonObject)?.int("maxSelection")?.let { + mutableEvents.tryEmit(SeatLayerEvent.SelectionLimitReached(it)) + } + "access.token.request" -> provideBuyerAccessToken(payload) + "access.expired" -> decodeBuyerAccessExpired(payload)?.let { + mutableEvents.tryEmit(SeatLayerEvent.BuyerAccessExpired(it)) + } + "access.unavailable" -> decodeBuyerAccessUnavailable(payload)?.let { + mutableEvents.tryEmit(SeatLayerEvent.BuyerAccessUnavailable(it)) + } + "selection.unavailable" -> decodeSelectedObjectsUnavailable(payload)?.let { + mutableEvents.tryEmit(SeatLayerEvent.SelectedObjectsUnavailable(it)) + } "hold.changed" -> decodeHold((payload as? JsonObject)?.get("hold")) ?.let { mutableEvents.tryEmit(SeatLayerEvent.HoldChanged(it)) } "hold.restored" -> decodeHold((payload as? JsonObject)?.get("hold")) @@ -174,6 +237,53 @@ public class SeatLayerController { } } + private fun provideBuyerAccessToken(payload: JsonElement?) { + val root = payload as? JsonObject ?: return + val requestId = root.string("requestId") ?: return + val reason = BuyerAccessRefreshReason( + root.string("reason") ?: BuyerAccessRefreshReason.Initial.raw, + ) + val provider = configuration?.buyerAccessTokenProvider + val activeClient = client ?: return + + generationScope.launch { + if (provider == null) { + runCatching { + activeClient.command( + "access.token.unavailable", + jsonObject("requestId" to JsonPrimitive(requestId)), + ) + } + return@launch + } + + try { + val token = provider.provide(BuyerAccessRequestContext(reason)) + check(token.token.isNotBlank() && token.expiresAt?.isFinite() != false) { + "buyer access provider returned an invalid token" + } + activeClient.command( + "access.token.provide", + jsonObject( + "requestId" to JsonPrimitive(requestId), + "token" to JsonPrimitive(token.token), + "expiresAt" to jsonNumber(token.expiresAt), + ), + ) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + // Never cross provider errors or bearer content into diagnostics. + runCatching { + activeClient.command( + "access.token.unavailable", + jsonObject("requestId" to JsonPrimitive(requestId)), + ) + } + } + } + } + private fun finishFailure(error: SeatLayerException) { val waiting = readyDeferred if (waiting != null && !waiting.isCompleted) waiting.completeExceptionally(error) @@ -266,6 +376,53 @@ public class SeatLayerController { .orEmpty() .mapNotNull(::decodeSelectedSeat) + public suspend fun selectObjects(objects: List): List = + ((run( + "selectObjects", + jsonObject("objects" to jsonStrings(objects)), + ) as? JsonObject)?.get("seats") as? JsonArray) + .orEmpty() + .mapNotNull(::decodeSelectedSeat) + + public suspend fun deselectObjects(objects: List) { + run("deselectObjects", jsonObject("objects" to jsonStrings(objects))) + } + + public suspend fun clearSelection(): Unit = run("clearSelection").let {} + + public suspend fun selectCategories(categoryKeys: List): List = + ((run( + "selectCategories", + jsonObject("categoryKeys" to jsonStrings(categoryKeys)), + ) as? JsonObject)?.get("seats") as? JsonArray) + .orEmpty() + .mapNotNull(::decodeSelectedSeat) + + public suspend fun deselectCategories(categoryKeys: List) { + run( + "deselectCategories", + jsonObject("categoryKeys" to jsonStrings(categoryKeys)), + ) + } + + public suspend fun setSelectableObjects(objects: List?) { + run( + "setSelectableObjects", + jsonObject("objects" to (objects?.let(::jsonStrings) ?: JsonNull)), + ) + } + + public suspend fun setMaxSelection(maximum: Int) { + require(maximum > 0) { "maximum must be positive" } + run("setMaxSelection", jsonObject("maxSelection" to JsonPrimitive(maximum))) + } + + public suspend fun getSelectionValidity(): SelectionValidity? = + decodeSelectionValidity((run("getSelectionValidity") as? JsonObject)?.get("validity")) + + public suspend fun refreshAccess(): Boolean = + (run("refreshAccess") as? JsonObject)?.boolean("refreshed") ?: false + public suspend fun getCurrentHold(): HoldResult? = decodeHold((run("getCurrentHold") as? JsonObject)?.get("hold")) @@ -305,6 +462,7 @@ public class SeatLayerController { client?.close() client = null mutableReady.value = null + generationScope.cancel() destroyed = true } @@ -312,5 +470,6 @@ public class SeatLayerController { client?.close() client = null mutableReady.value = null + generationScope.cancel() } } diff --git a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerView.kt b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerView.kt index d6de780..c1d766a 100644 --- a/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerView.kt +++ b/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerView.kt @@ -10,12 +10,10 @@ import android.webkit.CookieManager import android.webkit.RenderProcessGoneDetail import android.webkit.WebResourceError import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout -import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import kotlinx.coroutines.Dispatchers @@ -30,13 +28,6 @@ public class SeatLayerView @JvmOverloads constructor( public val controller: SeatLayerController = SeatLayerController() - private val assetLoader = WebViewAssetLoader.Builder() - .addPathHandler( - "/assets/", - WebViewAssetLoader.AssetsPathHandler(context), - ) - .build() - private val webView = WebView(context) private val bridgeSupported: Boolean @@ -78,15 +69,10 @@ public class SeatLayerView @JvmOverloads constructor( } CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false) webView.webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest, - ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) - override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, - ): Boolean = request.url.host != APP_HOST + ): Boolean = request.url.toString() != PAGE_URL override fun onReceivedError( view: WebView, @@ -170,9 +156,8 @@ public class SeatLayerView @JvmOverloads constructor( } private companion object { - const val APP_HOST = "appassets.androidplatform.net" - const val APP_ORIGIN = "https://$APP_HOST" - const val PAGE_URL = "$APP_ORIGIN/assets/index.html" + const val APP_ORIGIN = SEATLAYER_MOBILE_ORIGIN + const val PAGE_URL = SEATLAYER_MOBILE_PAGE_URL const val BRIDGE_OBJECT = "seatlayerAndroid" } } diff --git a/seatlayer/src/test/kotlin/io/seatlayer/android/ModelsTest.kt b/seatlayer/src/test/kotlin/io/seatlayer/android/ModelsTest.kt index 51d61f1..0a60cf9 100644 --- a/seatlayer/src/test/kotlin/io/seatlayer/android/ModelsTest.kt +++ b/seatlayer/src/test/kotlin/io/seatlayer/android/ModelsTest.kt @@ -2,10 +2,22 @@ package io.seatlayer.android import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class ModelsTest { + @Test + fun hostedAndFixtureVersionsRemainDistinctAndPinned() { + assertEquals("0.66.0", SEATLAYER_HOSTED_WEB_VERSION) + assertEquals("0.59.0", SEATLAYER_LEGACY_FIXTURE_WEB_VERSION) + assertEquals( + "https://cdn.seatlayer.io/seatlayer-js@$SEATLAYER_HOSTED_WEB_VERSION/mobile.html", + SEATLAYER_MOBILE_PAGE_URL, + ) + } + @Test fun decodesNestedConflictDetails() { val details = BridgeErrorDetails.decode( @@ -40,4 +52,49 @@ class ModelsTest { assertEquals("future-mode", ready.mode.raw) assertEquals("future-transport", ready.transport.raw) } + + @Test + fun privateSelectionConfigurationMatchesMobileBridgeContract() { + val configuration = SeatLayerConfiguration( + event = "ev_private", + buyerAccessToken = BuyerAccessToken("bse_seed", 123.0), + buyerAccessTokenProvider = BuyerAccessTokenProvider { + BuyerAccessToken("bse_${it.reason.raw}") + }, + selectedObjects = listOf("A-1"), + selectableObjects = listOf("A-1", "A-2"), + numberOfPlacesToSelect = 2, + selectionValidators = listOf( + SelectionValidator.MinimumSelectedPlaces(2), + SelectionValidator.ConsecutiveSeats, + ), + ) + val config = configuration.initPayload().objectValue("config")!! + assertEquals("bse_seed", config.objectValue("buyerAccessToken")?.string("token")) + assertEquals(true, config.boolean("nativeAccessProvider")) + assertEquals(2, config.int("numberOfPlacesToSelect")) + assertEquals(listOf("A-1"), config.array("selectedObjects")?.map { it.jsonPrimitive.content }) + assertEquals("minimumSelectedPlaces", config.array("selectionValidators") + ?.firstOrNull()?.let { it as JsonObject }?.string("type")) + assertTrue(configuration.usesPrivateAccess) + assertTrue(configuration.usesSelectionPolicy) + } + + @Test + fun decodesTypedSelectionAndAccessEventsLossTolerantly() { + val validity = decodeSelectionValidity( + bridgeJson.parseToJsonElement( + """{"isValid":false,"count":1,"required":2,"remaining":1,"seats":[{"id":"s1","label":"A-1"}],"violations":["futureRule"]}""", + ), + )!! + assertEquals("A-1", validity.seats.single().label) + assertEquals("futureRule", validity.violations.single().raw) + + val unavailable = decodeBuyerAccessUnavailable( + bridgeJson.parseToJsonElement( + """{"reason":"future_access_state","retryable":false}""", + ), + )!! + assertEquals("future_access_state", unavailable.reason.raw) + } }