Skip to content

Commit fe72a7a

Browse files
FitzAffulClaude Sonnet 4.6
andcommitted
feat: improve pairing flow, heartbeat, and demo header
Pairing: - Auto-check on launch: fires register immediately; shows device ID screen only on 404 - Persist pairing token in SharedPreferences to resume sessions across restarts - Pair-status now called as POST with pairing_token in request body - Fix DisposableEffect closure bug that cancelled new session on retry - Add button loading state on "Pair this device" tap Network: - Add X-Demo-Bypass: true header to all API calls via DemoBypassInterceptor - Add PairingTokenStore for pairing token persistence Heartbeat: - Move heartbeat from WorkManager (15-min minimum clamp) to coroutine loop inside the foreground service — now respects configured interval immediately - Fix CPU usage: /proc/stat is blocked on API 26+; use /proc/self/stat delta instead Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent af9bea3 commit fe72a7a

12 files changed

Lines changed: 415 additions & 246 deletions

File tree

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

Lines changed: 216 additions & 163 deletions
Large diffs are not rendered by default.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
44
import io.squarescreen.core.config.SquareScreenConfig
55
import io.squarescreen.core.datasource.NetworkDataSource
66
import io.squarescreen.network.api.SquareScreenApiService
7+
import io.squarescreen.network.interceptor.DemoBypassInterceptor
78
import io.squarescreen.network.interceptor.DeviceAuthInterceptor
89
import kotlinx.serialization.json.Json
910
import okhttp3.MediaType.Companion.toMediaType
@@ -33,6 +34,7 @@ object NetworkClientFactory {
3334

3435
private fun buildOkHttpClient(config: SquareScreenConfig): OkHttpClient {
3536
return OkHttpClient.Builder()
37+
.addInterceptor(DemoBypassInterceptor())
3638
.addInterceptor(DeviceAuthInterceptor(config.deviceId, config.deviceToken))
3739
.connectTimeout(30, TimeUnit.SECONDS)
3840
.readTimeout(30, TimeUnit.SECONDS)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import io.squarescreen.core.model.PairingStatus
66
import io.squarescreen.core.result.SquareScreenError
77
import io.squarescreen.core.result.SquareScreenResult
88
import io.squarescreen.network.api.PairingApiService
9+
import io.squarescreen.network.interceptor.DemoBypassInterceptor
10+
import io.squarescreen.network.dto.PairStatusRequestDto
911
import io.squarescreen.network.dto.RegisterRequestDto
1012
import kotlinx.serialization.json.Json
1113
import okhttp3.MediaType.Companion.toMediaType
@@ -30,6 +32,7 @@ class PairingNetworkClient {
3032
}
3133

3234
private val okHttpClient = OkHttpClient.Builder()
35+
.addInterceptor(DemoBypassInterceptor())
3336
.connectTimeout(30, TimeUnit.SECONDS)
3437
.readTimeout(30, TimeUnit.SECONDS)
3538
.writeTimeout(30, TimeUnit.SECONDS)
@@ -86,8 +89,11 @@ class PairingNetworkClient {
8689
* [PairingStatus.Expired], or [PairingStatus.Error] for unexpected failures.
8790
*/
8891
suspend fun getPairStatus(pairingToken: String): PairingStatus {
92+
if (pairingToken.isBlank()) {
93+
return PairingStatus.Error(IllegalStateException("Pairing token is missing — cannot check pair status"))
94+
}
8995
return try {
90-
val response = api.getPairStatus("Bearer $pairingToken")
96+
val response = api.getPairStatus(PairStatusRequestDto(pairingToken))
9197
when {
9298
response.isSuccessful -> {
9399
val body = response.body()
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package io.squarescreen.network.api
22

3+
import io.squarescreen.network.dto.PairStatusRequestDto
34
import io.squarescreen.network.dto.PairStatusResponseDto
45
import io.squarescreen.network.dto.RegisterRequestDto
56
import io.squarescreen.network.dto.RegisterResponseDto
67
import retrofit2.Response
78
import retrofit2.http.Body
8-
import retrofit2.http.GET
9-
import retrofit2.http.Header
109
import retrofit2.http.POST
1110

1211
internal interface PairingApiService {
@@ -16,8 +15,8 @@ internal interface PairingApiService {
1615
@Body body: RegisterRequestDto
1716
): Response<RegisterResponseDto>
1817

19-
@GET("screen/pair-status")
18+
@POST("screen/pair-status")
2019
suspend fun getPairStatus(
21-
@Header("Authorization") authHeader: String
20+
@Body body: PairStatusRequestDto
2221
): Response<PairStatusResponseDto>
2322
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ internal data class RegisterResponseDto(
1515
val message: String? = null
1616
)
1717

18+
@Serializable
19+
internal data class PairStatusRequestDto(
20+
@SerialName("pairing_token") val pairingToken: String
21+
)
22+
1823
@Serializable
1924
internal data class PairStatusResponseDto(
2025
val status: String,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.squarescreen.network.interceptor
2+
3+
import okhttp3.Interceptor
4+
import okhttp3.Response
5+
6+
internal class DemoBypassInterceptor : Interceptor {
7+
override fun intercept(chain: Interceptor.Chain): Response {
8+
val request = chain.request().newBuilder()
9+
.header("X-Demo-Bypass", "true")
10+
.build()
11+
return chain.proceed(request)
12+
}
13+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,6 @@ class SquareScreen private constructor(
186186
SquareScreenServiceLocator.nowPlayingState.value = result
187187
}
188188

189-
workScheduler.scheduleHeartbeat(config.heartbeatIntervalSeconds)
190189
workScheduler.scheduleEmergencyPoll(config.emergencyPollIntervalSeconds)
191190
workScheduler.scheduleCommandPoll()
192191

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

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package io.squarescreen.player
22

3+
import android.content.Context
34
import io.squarescreen.core.logging.SquareScreenLogger
45
import io.squarescreen.core.model.PairingStatus
56
import io.squarescreen.core.result.SquareScreenError
67
import io.squarescreen.core.result.SquareScreenResult
78
import io.squarescreen.network.PairingNetworkClient
9+
import io.squarescreen.player.internal.PairingTokenStore
810
import kotlinx.coroutines.CoroutineScope
911
import kotlinx.coroutines.Dispatchers
1012
import 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
*/
5359
class 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 02 → 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
}

squarescreen-player/src/main/kotlin/io/squarescreen/player/internal/DeviceMetricsCollector.kt

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,45 @@ private const val TAG = "DeviceMetricsCollector"
1212
internal class DeviceMetricsCollector(private val context: Context) {
1313

1414
/**
15-
* Reads CPU usage from /proc/stat.
16-
* Computes (total - idle) / total as a percentage.
17-
* Returns null if /proc/stat is unavailable or unreadable.
15+
* Estimates this process's CPU usage by sampling /proc/self/stat twice with a
16+
* short delay and computing the delta in jiffies.
17+
*
18+
* /proc/stat (system-wide) is blocked on API 26+, but /proc/self/stat (process-level)
19+
* remains accessible. The result reflects the player process's CPU consumption rather
20+
* than device-wide usage — appropriate for a signage SDK heartbeat.
21+
*
22+
* Returns null if the file is unreadable or the delta is zero (no time has elapsed).
1823
*/
1924
fun getCpuUsage(): Float? {
2025
return try {
21-
val lines = java.io.File("/proc/stat").readLines()
22-
val cpuLine = lines.firstOrNull { it.startsWith("cpu ") } ?: return null
23-
val parts = cpuLine.trim().split("\\s+".toRegex()).drop(1).mapNotNull { it.toLongOrNull() }
24-
if (parts.size < 4) return null
25-
val idle = parts[3]
26-
val total = parts.sum()
27-
if (total == 0L) return null
28-
((total - idle).toFloat() / total.toFloat() * 100f).coerceIn(0f, 100f)
26+
val sample1 = readProcessJiffies() ?: return null
27+
Thread.sleep(200)
28+
val sample2 = readProcessJiffies() ?: return null
29+
30+
val processDelta = (sample2.first - sample1.first).toFloat()
31+
val totalDelta = (sample2.second - sample1.second).toFloat()
32+
33+
if (totalDelta <= 0f) return null
34+
(processDelta / totalDelta * 100f).coerceIn(0f, 100f)
35+
} catch (e: Exception) {
36+
SquareScreenServiceLocator.log(TAG, "CPU usage unavailable: ${e.message}")
37+
null
38+
}
39+
}
40+
41+
// Returns Pair(processJiffies, totalJiffies) from /proc/self/stat and /proc/stat uptime.
42+
// Falls back to wall-clock total if /proc/stat is unavailable.
43+
private fun readProcessJiffies(): Pair<Long, Long>? {
44+
return try {
45+
val parts = java.io.File("/proc/self/stat").readText().trim().split(" ")
46+
if (parts.size < 17) return null
47+
// Fields 13,14 = utime, stime; 15,16 = cutime, cstime (all in jiffies)
48+
val utime = parts[13].toLongOrNull() ?: return null
49+
val stime = parts[14].toLongOrNull() ?: return null
50+
val processJiffies = utime + stime
51+
val totalJiffies = System.nanoTime() / 10_000_000L // nanoseconds → centiseconds (jiffies at 100Hz)
52+
Pair(processJiffies, totalJiffies)
2953
} catch (e: Exception) {
30-
// /proc/stat is restricted on API 26+ — this is expected, not an error.
31-
SquareScreenServiceLocator.log(TAG, "CPU usage unavailable (restricted on API 26+): ${e.message}")
3254
null
3355
}
3456
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.squarescreen.player.internal
2+
3+
import android.content.Context
4+
5+
private const val PREFS_FILE = "squarescreen_pairing"
6+
private const val KEY_PAIRING_TOKEN = "pairing_token"
7+
8+
internal class PairingTokenStore(context: Context) {
9+
private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
10+
11+
fun get(): String? = prefs.getString(KEY_PAIRING_TOKEN, null)
12+
13+
fun save(token: String) = prefs.edit().putString(KEY_PAIRING_TOKEN, token).apply()
14+
15+
fun clear() = prefs.edit().remove(KEY_PAIRING_TOKEN).apply()
16+
}

0 commit comments

Comments
 (0)