Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/publish-maven-central.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
)
}
```
Expand Down Expand Up @@ -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
Expand All @@ -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. |
Expand All @@ -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.
Expand All @@ -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

Expand Down
32 changes: 21 additions & 11 deletions docs/bridge.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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` |
Expand All @@ -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`
Expand All @@ -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.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion sample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ android {
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionName = "0.2.0"
}
}

Expand Down
76 changes: 32 additions & 44 deletions sample/src/main/kotlin/io/seatlayer/sample/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,65 +1,40 @@
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
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
}
}
}
Expand All @@ -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"
}
}
Loading