Device-side player SDK for DOOH (Digital Out-of-Home) digital signage on Android.
The SDK handles everything below the display layer: device pairing, playlist fetching, media caching, heartbeat reporting, emergency broadcast overrides, and remote device commands — so your app only needs to render what it receives.
| Artifact | What it does |
|---|---|
squarescreen-player |
Core engine — pairing, flows, background workers, foreground service |
squarescreen-ui |
Jetpack Compose player and emergency overlay (optional) |
squarescreen-core |
Shared models and contracts (transitive) |
squarescreen-network |
Retrofit network layer (transitive) |
squarescreen-cache |
Room metadata cache + disk media cache (transitive) |
Add to your module's build.gradle.kts:
dependencies {
// Core player (required)
implementation("io.squarescreen:squarescreen-player:0.1.3")
// Jetpack Compose UI components (optional)
implementation("io.squarescreen:squarescreen-ui:0.1.3")
}Minimum SDK: API 29 (Android 10)
Before calling SquareScreen.init(), a device must be paired with a SquareScreen workspace. There are two pairing paths — choose whichever fits your setup.
- An admin pre-registers the device in the SquareScreen dashboard using its Android ID.
- The SDK calls the register endpoint with that ID and polls for admin approval.
- On approval, the SDK receives a
deviceIdanddeviceToken— store them securely and pass them toSquareScreen.init().
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
val pairing = SquareScreenPairing.create(
context = applicationContext,
osIdentifier = androidId,
logger = if (BuildConfig.DEBUG) SquareScreenDebugLogger() else null
)
pairing.pairingStatus.collect { status ->
when (status) {
is PairingStatus.Approved -> {
// Store securely — token is issued exactly once
credentialStore.save(status.deviceId, status.deviceToken)
initializeSdk(status.deviceId, status.deviceToken)
}
PairingStatus.Pending -> showAwaitingApprovalUI()
PairingStatus.DeviceNotFound -> showDeviceNotFoundUI(androidId)
PairingStatus.AlreadyPaired -> initializeFromStoredCredentials()
PairingStatus.Expired -> showExpiredUI()
PairingStatus.InvalidToken -> showInvalidTokenUI()
is PairingStatus.Error -> showGenericError(status.throwable)
}
}Use this when an admin has already created a device in the SquareScreen dashboard and given you its device ID. You supply that ID alongside your own device token — the token can be anything stable (IMEI, installation UUID, etc.), it is your choice.
- Admin creates the device in the SquareScreen dashboard — this generates the
deviceId. - The SDK calls the activate endpoint with that
deviceIdand your chosendeviceToken, then polls for admin approval exactly like Path 1. - On approval, call
SquareScreen.init()with the samedeviceIdanddeviceToken.
val pairing = SquareScreenPairing.createWithActivation(
context = applicationContext,
deviceId = "AB12CD34", // created in the SquareScreen admin dashboard
deviceToken = telephonyManager.imei ?: myInstallationUuid,
logger = if (BuildConfig.DEBUG) SquareScreenDebugLogger() else null
)
pairing.pairingStatus.collect { status ->
when (status) {
is PairingStatus.Approved -> {
credentialStore.save(status.deviceId, status.deviceToken)
initializeSdk(status.deviceId, status.deviceToken)
}
PairingStatus.Pending -> showAwaitingApprovalUI()
PairingStatus.DeviceNotFound -> showDeviceNotFoundUI()
PairingStatus.AlreadyPaired -> initializeFromStoredCredentials()
PairingStatus.Expired -> showExpiredUI()
PairingStatus.InvalidToken -> showInvalidTokenUI()
is PairingStatus.Error -> showGenericError(status.throwable)
}
}- On first launch (no stored token): calls
register, persists the returned pairing token, then begins pollingpair-status. - On subsequent launches (stored token): skips
registerand goes straight to polling. - Polls with exponential backoff: 5 s × 3 attempts → 10 s × 3 → 30 s indefinitely.
- The pairing token is automatically cleared on
Approved,Expired, andInvalidToken. - Call
pairing.cancel()when the pairing screen is destroyed.
// One-shot check outside the automatic polling cycle
val status = pairing.checkPairStatus()Tip:
DeviceNotFoundmeans the admin hasn't pre-registered this device yet. Show the Android ID on screen so the admin can copy it into the dashboard.
Once the device is paired and credentials are stored, initialize the SDK once in Application.onCreate():
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
SquareScreen.init(
context = this,
config = SquareScreenConfig(
deviceId = credentialStore.deviceId,
deviceToken = credentialStore.deviceToken,
foregroundNotification = ForegroundNotificationConfig(
title = "Display active",
iconResId = R.drawable.ic_player
),
logger = if (BuildConfig.DEBUG) SquareScreenDebugLogger() else null
)
)
}
}Security: never hardcode
deviceIdordeviceToken. Store them inEncryptedSharedPreferencesafter the pairing flow completes.
@Composable
fun PlayerScreen() {
val squareScreen = SquareScreen.getInstance()
SquareScreenDisplay(
squareScreen = squareScreen,
modifier = Modifier.fillMaxSize()
)
}SquareScreenDisplay renders the active playlist, overlays emergency alerts, and automatically reports proof-of-play after each item.
SquareScreenConfig(
deviceId = "...", // required — X-Device-Id header
deviceToken = "...", // required — X-Device-Token header
heartbeatIntervalSeconds = 60L, // min 30, default 60
emergencyPollIntervalSeconds = 30L, // min 15, default 30
cacheTtlSeconds = 3600L, // default 1 hour
foregroundNotification = ForegroundNotificationConfig(
title = "Display active",
iconResId = R.drawable.ic_player
),
logger = SquareScreenDebugLogger() // null = silent (recommended for production)
)The API base URL is managed internally by the SDK — debug builds target staging, release builds target production. Integrators do not set it.
The SDK exposes four Kotlin Flows on the SquareScreen instance:
val squareScreen = SquareScreen.getInstance()
// Active playlist — emits on every schedule change or manual refresh
squareScreen.nowPlaying.collect { result ->
when (result) {
is SquareScreenResult.Success -> render(result.data)
is SquareScreenResult.Error -> showError(result.error)
}
}
// Emergency alert — emits null when no broadcast is active
squareScreen.emergencyAlert.collect { alert ->
if (alert != null) showEmergencyOverlay(alert) else hideOverlay()
}
// Device connectivity and sync state
squareScreen.deviceStatus.collect { status ->
when (status) {
DeviceStatus.ONLINE -> hideOfflineBadge()
DeviceStatus.OFFLINE -> showOfflineBadge()
DeviceStatus.SYNCING -> showSpinner()
DeviceStatus.CONNECTING -> Unit
}
}
// Remote commands issued by the management console
squareScreen.commands.collect { commands ->
commands.forEach { command ->
when (command.toCommandType()) {
is CommandType.SetVolume -> setVolume((command.toCommandType() as CommandType.SetVolume).volume)
is CommandType.Unknown -> { /* ignore unknown commands */ }
}
squareScreen.acknowledgeCommand(command.id, status = "completed")
}
}For code that can't use collect, all flows have callback wrappers:
val job = squareScreen.nowPlayingCallback(
scope = lifecycleScope,
onSuccess = { playlist -> render(playlist) },
onError = { error -> showError(error) }
)
// Cancel when done
job.cancel()The SDK sends device health metrics (CPU usage, memory, disk, temperature) to the server at the configured heartbeatIntervalSeconds interval. This runs as a coroutine loop inside the foreground service — not WorkManager — so the configured interval is respected exactly without the 15-minute WorkManager floor.
CPU usage is measured at the process level via /proc/self/stat (system-wide /proc/stat is restricted on API 26+). Temperature reporting is best-effort and varies by manufacturer; the SDK always sends null rather than throw if unavailable.
Emergency broadcasts automatically override the playlist UI when using SquareScreenDisplay. The overlay renders the alert's title, message, backgroundColor, and textColor as provided by the server.
If you are not using squarescreen-ui, subscribe to squareScreen.emergencyAlert and implement your own overlay.
SquareScreenDisplay reports playback automatically. If you use SquareScreenPlayerView directly, wire the onItemCompleted callback:
SquareScreenPlayerView(
nowPlaying = squareScreen.nowPlaying,
onItemCompleted = { item, startedAt, endedAt ->
scope.launch {
squareScreen.reportPlayback(
PlaybackReport(
mediaUuid = item.id,
playlistUuid = currentPlaylist?.playlist?.uuid,
scheduleUuid = currentPlaylist?.schedule?.uuid,
startedAt = formatIso8601(startedAt),
endedAt = formatIso8601(endedAt),
durationSeconds = item.duration,
completed = true
)
)
}
}
)The SDK caches both playlist metadata and media files so content continues to play when the device is offline.
| Layer | Storage | What's cached |
|---|---|---|
| Metadata | Room database (squarescreen.db) |
Playlist and playlist item records |
| Media files | external files dir / squarescreen_media |
Images and video, keyed by SHA-256 hash of URL |
- TTL: A cached playlist is considered fresh for
cacheTtlSeconds(default 3600 s / 1 hour). After expiry the SDK fetches a fresh copy on the next request. - Offline fallback: If the network fetch fails and a cached playlist exists (even expired), the SDK serves it and emits
DeviceStatus.OFFLINE. - Deduplication: Media files are stored under a SHA-256 hash of the source URL, so the same file is never downloaded twice even if it appears in multiple playlists.
- Storage permissions: The media cache directory lives in the app's private external storage (
context.getExternalFilesDir()). NoREAD_EXTERNAL_STORAGEorWRITE_EXTERNAL_STORAGEpermission is required on API 29+.
Implement CacheProvider to plug in your own storage backend:
class MyCustomCacheProvider : CacheProvider {
override suspend fun getPlaylist(): Playlist? { ... }
override suspend fun savePlaylist(playlist: Playlist) { ... }
override suspend fun getMediaFile(url: String): File? { ... }
override suspend fun saveMediaFile(url: String, file: File) { ... }
override suspend fun clearAll() { ... }
}
SquareScreenConfig(
...
cacheProvider = MyCustomCacheProvider()
)| Task | Mechanism | Interval | Notes |
|---|---|---|---|
| Heartbeat | Foreground service coroutine loop | heartbeatIntervalSeconds (default 60 s) |
Exact interval — not subject to WorkManager's 15-min floor |
| Emergency poll | WorkManager PeriodicWorkRequest |
emergencyPollIntervalSeconds (default 30 s) |
Survives process death |
| Command poll | WorkManager PeriodicWorkRequest |
30 s | Survives process death |
The squarescreen-player module has no UI dependency. Use it headlessly — collect nowPlaying and render however you like:
// Add only the player, skip the UI module
implementation("io.squarescreen:squarescreen-player:0.1.3")squareScreen.nowPlaying.collect { result ->
val playlist = result.getOrNull() ?: return@collect
myCustomRenderer.show(playlist.items)
}// Force a playlist refresh (bypasses TTL)
val result = squareScreen.refresh()
// Force an emergency status check
val alert = squareScreen.checkEmergency()
// Stop the foreground service and cancel all background workers
squareScreen.shutdown()The SDK never calls android.util.Log directly. All internal logs route through SquareScreenLogger.
// Development — writes to Logcat
logger = SquareScreenDebugLogger()
// Production — silent (recommended)
logger = null
// Custom — e.g. forward to Crashlytics
logger = object : SquareScreenLogger {
override fun debug(tag: String, message: String) {}
override fun info(tag: String, message: String) {}
override fun warn(tag: String, message: String) {}
override fun error(tag: String, message: String, throwable: Throwable?) {
FirebaseCrashlytics.getInstance().recordException(throwable ?: Exception(message))
}
}- Android API 29+
- Kotlin 2.0+
- Jetpack Compose (only if using
squarescreen-ui)
Copyright 2024 SquareScreen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.