Skip to content

Commit 894e623

Browse files
committed
feat(pairing): align activate path with actual API response shape
1 parent 9092415 commit 894e623

7 files changed

Lines changed: 134 additions & 80 deletions

File tree

sample-app/src/main/kotlin/io/squarescreen/sample/ui/PairingScreen.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ private fun RegisterContent(
219219
message = s.throwable.message ?: "An unexpected error occurred.",
220220
onRetry = onRetry
221221
)
222+
223+
// Not emitted by the register path — treat as unexpected error
224+
PairingStatus.IdentifierMismatch -> PairingErrorContent(
225+
title = "Something went wrong",
226+
message = "An unexpected error occurred.",
227+
onRetry = onRetry
228+
)
222229
}
223230
}
224231

@@ -322,6 +329,12 @@ private fun ActivateContent(
322329
onRetry = null
323330
)
324331

332+
PairingStatus.IdentifierMismatch -> PairingErrorContent(
333+
title = "Identifier mismatch",
334+
message = "This device ID is bound to a different token. Contact your admin to re-issue the device ID.",
335+
onRetry = onRetry
336+
)
337+
325338
PairingStatus.Expired -> PairingErrorContent(
326339
title = "Pairing window expired",
327340
message = "The 10-minute approval window closed. Tap below to try again.",

squarescreen-core/api/squarescreen-core.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,13 @@ public final class io/squarescreen/core/model/PairingStatus$Expired : io/squares
301301
public fun toString ()Ljava/lang/String;
302302
}
303303

304+
public final class io/squarescreen/core/model/PairingStatus$IdentifierMismatch : io/squarescreen/core/model/PairingStatus {
305+
public static final field INSTANCE Lio/squarescreen/core/model/PairingStatus$IdentifierMismatch;
306+
public fun equals (Ljava/lang/Object;)Z
307+
public fun hashCode ()I
308+
public fun toString ()Ljava/lang/String;
309+
}
310+
304311
public final class io/squarescreen/core/model/PairingStatus$InvalidToken : io/squarescreen/core/model/PairingStatus {
305312
public static final field INSTANCE Lio/squarescreen/core/model/PairingStatus$InvalidToken;
306313
public fun equals (Ljava/lang/Object;)Z

squarescreen-core/src/main/kotlin/io/squarescreen/core/model/PairingStatus.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ sealed class PairingStatus {
4646
*/
4747
data object AlreadyPaired : PairingStatus()
4848

49+
/**
50+
* The device ID was activated with a different device token than the one supplied.
51+
* Only emitted by the activate path ([io.squarescreen.player.SquareScreenPairing.createWithActivation]).
52+
* Contact your admin to re-issue the device ID with the correct token.
53+
*/
54+
data object IdentifierMismatch : PairingStatus()
55+
4956
/** An unexpected error occurred. Inspect [throwable] for details. */
5057
data class Error(val throwable: Throwable) : PairingStatus()
5158
}

squarescreen-network/src/main/kotlin/io/squarescreen/network/PairingNetworkClient.kt

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import io.squarescreen.core.result.SquareScreenResult
88
import io.squarescreen.network.api.PairingApiService
99
import io.squarescreen.network.interceptor.DemoBypassInterceptor
1010
import io.squarescreen.network.dto.ActivateRequestDto
11+
import io.squarescreen.network.dto.PairingErrorDto
1112
import io.squarescreen.network.dto.PairStatusRequestDto
1213
import io.squarescreen.network.dto.RegisterRequestDto
1314
import kotlinx.serialization.json.Json
@@ -86,42 +87,46 @@ class PairingNetworkClient {
8687
/**
8788
* Calls `POST /screen/activate` with the developer-supplied device ID and token.
8889
*
89-
* An alternative to [register] for integrators who already hold a SquareScreen device ID
90-
* (created in the admin dashboard) and supply their own device token (IMEI, UUID, etc.).
91-
* Returns the same [PairingRegistration] as [register] on success.
90+
* Unlike [register], activate resolves immediately — the server returns the permanent
91+
* device token on 200 with no pair-status polling required.
92+
*
93+
* Returns [PairingStatus.Approved] on success, or the appropriate terminal
94+
* [PairingStatus] on failure:
95+
* - [PairingStatus.InvalidToken] — device ID is invalid or expired (401)
96+
* - [PairingStatus.AlreadyPaired] — device is already paired (409 ALREADY_PAIRED)
97+
* - [PairingStatus.IdentifierMismatch] — device ID is bound to a different token (409 IDENTIFIER_MISMATCH)
9298
*/
93-
suspend fun activate(deviceId: String, deviceToken: String): SquareScreenResult<PairingRegistration> {
99+
suspend fun activate(deviceId: String, deviceToken: String): PairingStatus {
94100
return try {
95101
val response = api.activate(ActivateRequestDto(deviceId, deviceToken))
96102
when {
97103
response.isSuccessful -> {
98104
val body = response.body()
99-
?: return SquareScreenResult.Error(
100-
SquareScreenError.ParseError("Activate response body was null")
101-
)
102-
SquareScreenResult.Success(
103-
PairingRegistration(
104-
pairingToken = body.pairingToken,
105-
expiresIn = body.expiresIn
105+
?: return PairingStatus.Error(
106+
IllegalStateException("Activate response body was null")
106107
)
107-
)
108+
PairingStatus.Approved(deviceId, body.deviceToken)
108109
}
109-
response.code() == 404 ->
110-
SquareScreenResult.Error(SquareScreenError.NetworkError(404, "DEVICE_NOT_FOUND"))
111-
response.code() == 409 ->
112-
SquareScreenResult.Error(SquareScreenError.NetworkError(409, "ALREADY_PAIRED"))
113-
else ->
114-
SquareScreenResult.Error(
115-
SquareScreenError.NetworkError(response.code(), response.message())
116-
)
110+
response.code() == 401 -> PairingStatus.InvalidToken
111+
response.code() == 409 -> {
112+
val errorCode = parseErrorCode(response.errorBody()?.string())
113+
when (errorCode) {
114+
"ALREADY_PAIRED" -> PairingStatus.AlreadyPaired
115+
"IDENTIFIER_MISMATCH" -> PairingStatus.IdentifierMismatch
116+
else -> PairingStatus.Error(Exception("409: $errorCode"))
117+
}
118+
}
119+
else -> PairingStatus.Error(
120+
Exception("Unexpected HTTP ${response.code()}: ${response.message()}")
121+
)
117122
}
118123
} catch (e: Exception) {
119-
SquareScreenResult.Error(SquareScreenError.Unknown(e))
124+
PairingStatus.Error(e)
120125
}
121126
}
122127

123128
/**
124-
* Calls `GET /screen/pair-status` using the pairing token from [register].
129+
* Calls `POST /screen/pair-status` using the pairing token from [register].
125130
*
126131
* Returns [PairingStatus.Pending], [PairingStatus.Approved], [PairingStatus.InvalidToken],
127132
* [PairingStatus.Expired], or [PairingStatus.Error] for unexpected failures.
@@ -141,15 +146,15 @@ class PairingNetworkClient {
141146
when (body.status) {
142147
"pending" -> PairingStatus.Pending
143148
"approved" -> {
144-
val deviceId = body.deviceId
149+
val id = body.deviceId
145150
?: return PairingStatus.Error(
146151
IllegalStateException("Approved response missing device_id")
147152
)
148-
val deviceToken = body.deviceToken
153+
val token = body.deviceToken
149154
?: return PairingStatus.Error(
150155
IllegalStateException("Approved response missing device_token")
151156
)
152-
PairingStatus.Approved(deviceId, deviceToken)
157+
PairingStatus.Approved(id, token)
153158
}
154159
else -> PairingStatus.Error(
155160
IllegalStateException("Unknown pair-status value: '${body.status}'")
@@ -166,4 +171,13 @@ class PairingNetworkClient {
166171
PairingStatus.Error(e)
167172
}
168173
}
174+
175+
private fun parseErrorCode(errorBody: String?): String? {
176+
if (errorBody == null) return null
177+
return try {
178+
json.decodeFromString<PairingErrorDto>(errorBody).code
179+
} catch (e: Exception) {
180+
null
181+
}
182+
}
169183
}

squarescreen-network/src/main/kotlin/io/squarescreen/network/api/PairingApiService.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.squarescreen.network.api
22

33
import io.squarescreen.network.dto.ActivateRequestDto
4+
import io.squarescreen.network.dto.ActivateResponseDto
45
import io.squarescreen.network.dto.PairStatusRequestDto
56
import io.squarescreen.network.dto.PairStatusResponseDto
67
import io.squarescreen.network.dto.RegisterRequestDto
@@ -19,7 +20,7 @@ internal interface PairingApiService {
1920
@POST("screen/activate")
2021
suspend fun activate(
2122
@Body body: ActivateRequestDto
22-
): Response<RegisterResponseDto>
23+
): Response<ActivateResponseDto>
2324

2425
@POST("screen/pair-status")
2526
suspend fun getPairStatus(

squarescreen-network/src/main/kotlin/io/squarescreen/network/dto/PairingDto.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ internal data class ActivateRequestDto(
1414
@SerialName("device_token") val deviceToken: String
1515
)
1616

17+
@Serializable
18+
internal data class ActivateResponseDto(
19+
val status: String,
20+
@SerialName("device_token") val deviceToken: String,
21+
val message: String? = null
22+
)
23+
1724
@Serializable
1825
internal data class RegisterResponseDto(
1926
@SerialName("pairing_token") val pairingToken: String,

squarescreen-player/src/main/kotlin/io/squarescreen/player/SquareScreenPairing.kt

Lines changed: 59 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -78,68 +78,73 @@ class SquareScreenPairing private constructor(
7878
* The last emitted value is replayed to new collectors.
7979
*/
8080
val pairingStatus: Flow<PairingStatus> = callbackFlow<PairingStatus> {
81-
val storedToken = tokenStore.get()
82-
83-
if (storedToken != null) {
84-
logger?.debug(TAG, "Resuming pairing with stored token")
85-
currentPairingToken = storedToken
86-
send(PairingStatus.Pending)
87-
} else {
88-
val result = when (val mode = registrationMode) {
89-
is RegistrationMode.Register -> {
90-
logger?.debug(TAG, "Registering device: ${mode.osIdentifier}")
91-
networkClient.register(mode.osIdentifier)
92-
}
93-
is RegistrationMode.Activate -> {
94-
logger?.debug(TAG, "Activating device: ${mode.deviceId}")
95-
networkClient.activate(mode.deviceId, mode.deviceToken)
96-
}
81+
when (val mode = registrationMode) {
82+
83+
// Activate path — resolves in a single network call, no polling.
84+
is RegistrationMode.Activate -> {
85+
logger?.debug(TAG, "Activating device: ${mode.deviceId}")
86+
val status = networkClient.activate(mode.deviceId, mode.deviceToken)
87+
logger?.debug(TAG, "Activate result: $status")
88+
send(status)
89+
close()
9790
}
9891

99-
when (result) {
100-
is SquareScreenResult.Error -> {
101-
val status = result.error.toPairingStatus()
102-
logger?.debug(TAG, "Registration failed: $status")
103-
send(status)
104-
close()
105-
return@callbackFlow
106-
}
107-
is SquareScreenResult.Success -> {
108-
currentPairingToken = result.data.pairingToken
109-
tokenStore.save(result.data.pairingToken)
110-
logger?.debug(TAG, "Token stored (expires in ${result.data.expiresIn}s)")
92+
// Register path — gets a short-lived pairing token then polls pair-status.
93+
is RegistrationMode.Register -> {
94+
val storedToken = tokenStore.get()
95+
96+
if (storedToken != null) {
97+
logger?.debug(TAG, "Resuming pairing with stored token")
98+
currentPairingToken = storedToken
11199
send(PairingStatus.Pending)
100+
} else {
101+
logger?.debug(TAG, "Registering device: ${mode.osIdentifier}")
102+
when (val reg = networkClient.register(mode.osIdentifier)) {
103+
is SquareScreenResult.Error -> {
104+
val status = reg.error.toPairingStatus()
105+
logger?.debug(TAG, "Register failed: $status")
106+
send(status)
107+
close()
108+
return@callbackFlow
109+
}
110+
is SquareScreenResult.Success -> {
111+
currentPairingToken = reg.data.pairingToken
112+
tokenStore.save(reg.data.pairingToken)
113+
logger?.debug(TAG, "Token stored (expires in ${reg.data.expiresIn}s)")
114+
send(PairingStatus.Pending)
115+
}
116+
}
112117
}
113-
}
114-
}
115118

116-
var attempt = 0
117-
while (true) {
118-
delay(backoffDelay(attempt))
119-
attempt++
120-
val token = currentPairingToken ?: break
121-
logger?.debug(TAG, "Polling pair status (attempt $attempt)")
122-
val status = networkClient.getPairStatus(token)
123-
send(status)
124-
when (status) {
125-
is PairingStatus.Approved,
126-
PairingStatus.InvalidToken,
127-
PairingStatus.Expired -> {
128-
logger?.debug(TAG, "Pairing terminal: $status — clearing stored token")
129-
tokenStore.clear()
130-
close()
131-
return@callbackFlow
132-
}
133-
is PairingStatus.Error -> {
134-
logger?.debug(TAG, "Pairing error: ${status.throwable.message}")
135-
close()
136-
return@callbackFlow
119+
var attempt = 0
120+
while (true) {
121+
delay(backoffDelay(attempt))
122+
attempt++
123+
val token = currentPairingToken ?: break
124+
logger?.debug(TAG, "Polling pair status (attempt $attempt)")
125+
val status = networkClient.getPairStatus(token)
126+
send(status)
127+
when (status) {
128+
is PairingStatus.Approved,
129+
PairingStatus.InvalidToken,
130+
PairingStatus.Expired -> {
131+
logger?.debug(TAG, "Pairing terminal: $status — clearing stored token")
132+
tokenStore.clear()
133+
close()
134+
return@callbackFlow
135+
}
136+
is PairingStatus.Error -> {
137+
logger?.debug(TAG, "Pairing error: ${status.throwable.message}")
138+
close()
139+
return@callbackFlow
140+
}
141+
else -> {}
142+
}
137143
}
138-
else -> {}
144+
145+
awaitClose()
139146
}
140147
}
141-
142-
awaitClose()
143148
}.shareIn(scope, SharingStarted.Lazily, replay = 1)
144149

145150
/**

0 commit comments

Comments
 (0)