11package io.squarescreen.player
22
3+ import android.content.Context
34import io.squarescreen.core.logging.SquareScreenLogger
45import io.squarescreen.core.model.PairingStatus
56import io.squarescreen.core.result.SquareScreenError
67import io.squarescreen.core.result.SquareScreenResult
78import io.squarescreen.network.PairingNetworkClient
9+ import io.squarescreen.player.internal.PairingTokenStore
810import kotlinx.coroutines.CoroutineScope
911import kotlinx.coroutines.Dispatchers
1012import kotlinx.coroutines.SupervisorJob
@@ -21,38 +23,43 @@ private const val TAG = "SquareScreenPairing"
2123/* *
2224 * Manages the device pairing flow before [SquareScreen.init] can be called.
2325 *
26+ * On first collection of [pairingStatus]:
27+ * - If a pairing token is stored from a previous session, skips register and goes
28+ * straight to polling `pair-status`.
29+ * - Otherwise calls `register`, persists the returned token, then polls `pair-status`.
30+ *
31+ * The pairing token is cleared automatically on [PairingStatus.Approved],
32+ * [PairingStatus.Expired], and [PairingStatus.InvalidToken].
33+ *
2434 * Usage:
2535 * ```kotlin
26- * val pairing = SquareScreenPairing.create(osIdentifier = androidId)
36+ * val pairing = SquareScreenPairing.create(context, osIdentifier = androidId)
2737 *
28- * // Automatic — observe status changes with backoff polling
2938 * pairing.pairingStatus.collect { status ->
3039 * when (status) {
3140 * is PairingStatus.Approved -> {
3241 * credentialStore.save(status.deviceId, status.deviceToken)
3342 * initializeSdk(status.deviceId, status.deviceToken)
3443 * }
35- * PairingStatus.Pending -> showAwaitingApprovalUI()
44+ * PairingStatus.Pending -> showAwaitingApprovalUI()
3645 * PairingStatus.DeviceNotFound -> showDeviceNotFoundError()
3746 * PairingStatus.AlreadyPaired -> showAlreadyPairedError()
38- * PairingStatus.Expired -> showExpiredError()
39- * PairingStatus.InvalidToken -> showInvalidTokenError()
40- * is PairingStatus.Error -> showGenericError(status.throwable)
47+ * PairingStatus.Expired -> showExpiredError()
48+ * PairingStatus.InvalidToken -> showInvalidTokenError()
49+ * is PairingStatus.Error -> showGenericError(status.throwable)
4150 * }
4251 * }
4352 *
44- * // Manual — trigger an immediate check (e.g. from a "Check now" button)
45- * val status = pairing.checkPairStatus()
46- *
47- * // Cancel when done (e.g. in onDestroy or DisposableEffect)
48- * pairing.cancel()
53+ * val status = pairing.checkPairStatus() // manual one-shot check
54+ * pairing.cancel() // call in onDestroy / DisposableEffect
4955 * ```
5056 *
5157 * @param osIdentifier A stable device identifier pre-registered by an admin (e.g. Android ID).
5258 */
5359class SquareScreenPairing private constructor(
5460 private val osIdentifier : String ,
5561 private val networkClient : PairingNetworkClient ,
62+ private val tokenStore : PairingTokenStore ,
5663 private val logger : SquareScreenLogger ? = null
5764) {
5865 private val scope = CoroutineScope (SupervisorJob () + Dispatchers .IO )
@@ -62,32 +69,37 @@ class SquareScreenPairing private constructor(
6269 /* *
6370 * Flow of [PairingStatus] updates.
6471 *
65- * On first collection: calls `POST /screen/register`, then polls `GET /screen/pair-status`
66- * with exponential backoff (5 s × 3 → 10 s × 3 → 30 s thereafter).
67- * Terminates automatically on [PairingStatus.Approved], [PairingStatus.InvalidToken],
68- * [PairingStatus.Expired], [PairingStatus.DeviceNotFound], [PairingStatus.AlreadyPaired],
69- * or [PairingStatus.Error].
72+ * On first collection:
73+ * - Stored token found → skips register, polls pair-status immediately.
74+ * - No stored token → calls register, stores token, then polls pair-status.
7075 *
71- * The last emitted value is replayed to new collectors via [shareIn].
76+ * Polls with exponential backoff (5 s × 3 → 10 s × 3 → 30 s thereafter).
77+ * Terminates automatically on any terminal [PairingStatus].
78+ * The last emitted value is replayed to new collectors.
7279 */
7380 val pairingStatus: Flow <PairingStatus > = callbackFlow<PairingStatus > {
74- logger?.debug( TAG , " Registering device with identifier: $osIdentifier " )
81+ val storedToken = tokenStore.get( )
7582
76- when (val reg = networkClient.register(osIdentifier)) {
77- is SquareScreenResult .Error -> {
78- val status = reg.error.toPairingStatus()
79- logger?.debug(TAG , " Register failed: $status " )
80- send(status)
81- close()
82- return @callbackFlow
83- }
84- is SquareScreenResult .Success -> {
85- currentPairingToken = reg.data.pairingToken
86- logger?.debug(
87- TAG ,
88- " Registered — awaiting admin approval (token expires in ${reg.data.expiresIn} s)"
89- )
90- send(PairingStatus .Pending )
83+ if (storedToken != null ) {
84+ logger?.debug(TAG , " Resuming pairing with stored token" )
85+ currentPairingToken = storedToken
86+ send(PairingStatus .Pending )
87+ } else {
88+ logger?.debug(TAG , " Registering device with identifier: $osIdentifier " )
89+ when (val reg = networkClient.register(osIdentifier)) {
90+ is SquareScreenResult .Error -> {
91+ val status = reg.error.toPairingStatus()
92+ logger?.debug(TAG , " Register failed: $status " )
93+ send(status)
94+ close()
95+ return @callbackFlow
96+ }
97+ is SquareScreenResult .Success -> {
98+ currentPairingToken = reg.data.pairingToken
99+ tokenStore.save(reg.data.pairingToken)
100+ logger?.debug(TAG , " Registered — token stored (expires in ${reg.data.expiresIn} s)" )
101+ send(PairingStatus .Pending )
102+ }
91103 }
92104 }
93105
@@ -96,15 +108,20 @@ class SquareScreenPairing private constructor(
96108 delay(backoffDelay(attempt))
97109 attempt++
98110 val token = currentPairingToken ? : break
99- logger?.debug(TAG , " Polling pair status (attempt $attempt , delay ${backoffDelay(attempt - 1 )} ms )" )
111+ logger?.debug(TAG , " Polling pair status (attempt $attempt )" )
100112 val status = networkClient.getPairStatus(token)
101113 send(status)
102114 when (status) {
103115 is PairingStatus .Approved ,
104116 PairingStatus .InvalidToken ,
105- PairingStatus .Expired ,
117+ PairingStatus .Expired -> {
118+ logger?.debug(TAG , " Pairing terminal: $status — clearing stored token" )
119+ tokenStore.clear()
120+ close()
121+ return @callbackFlow
122+ }
106123 is PairingStatus .Error -> {
107- logger?.debug(TAG , " Pairing terminal : $status " )
124+ logger?.debug(TAG , " Pairing error : ${ status.throwable.message} " )
108125 close()
109126 return @callbackFlow
110127 }
@@ -119,7 +136,7 @@ class SquareScreenPairing private constructor(
119136 * Performs a one-shot pair-status check outside the automatic polling cycle.
120137 *
121138 * Only valid after [pairingStatus] has been collected and the register call has completed.
122- * Returns [PairingStatus.Error] if called before an active pairing session exists .
139+ * Returns [PairingStatus.Error] if there is no active pairing token .
123140 */
124141 suspend fun checkPairStatus (): PairingStatus {
125142 val token = currentPairingToken
@@ -130,14 +147,13 @@ class SquareScreenPairing private constructor(
130147 }
131148
132149 /* *
133- * Cancels the polling coroutine scope. Call this when the pairing screen is destroyed
134- * to avoid leaking the polling loop.
150+ * Cancels the polling coroutine scope. Call this when the pairing screen is destroyed.
135151 */
136152 fun cancel () {
137153 scope.cancel()
138154 }
139155
140- // Backoff schedule : attempts 0- 2 → 5s, attempts 3- 5 → 10s, attempts 6+ → 30s
156+ // Backoff: attempts 0– 2 → 5s, 3– 5 → 10s, 6+ → 30s
141157 private fun backoffDelay (attempt : Int ): Long = when {
142158 attempt < 3 -> 5_000L
143159 attempt < 6 -> 10_000L
@@ -146,18 +162,25 @@ class SquareScreenPairing private constructor(
146162
147163 companion object {
148164 /* *
149- * Creates a [SquareScreenPairing] instance for the given OS identifier .
165+ * Creates a [SquareScreenPairing] instance.
150166 *
167+ * @param context Application or Activity context used for persisting the pairing token.
151168 * @param osIdentifier Stable device identifier pre-registered by an admin.
152169 * On Android, use `Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)`.
153- * @param logger Optional logger for debug output. Pass null (default) for silence .
170+ * @param logger Optional logger for debug output. Null (default) = silent .
154171 */
155172 fun create (
173+ context : Context ,
156174 osIdentifier : String ,
157175 logger : SquareScreenLogger ? = null
158176 ): SquareScreenPairing {
159177 require(osIdentifier.isNotBlank()) { " osIdentifier must not be blank" }
160- return SquareScreenPairing (osIdentifier, PairingNetworkClient (), logger)
178+ return SquareScreenPairing (
179+ osIdentifier = osIdentifier,
180+ networkClient = PairingNetworkClient (),
181+ tokenStore = PairingTokenStore (context.applicationContext),
182+ logger = logger
183+ )
161184 }
162185 }
163186}
0 commit comments