From 6d4168c1574fcd96b3c55ce14117224a726fee89 Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Sun, 7 Sep 2025 03:09:30 +0530 Subject: [PATCH 1/4] feat: Introduce public Polling API facade and internalize engine This commit refactors the PollingEngine by introducing a public `PollingApi` interface and a `Polling` facade object as the primary entry point for consumers. The `PollingEngine` itself is now internal, encapsulating the implementation details. **Key Changes:** * **Public API Facade (`Polling.kt`, `PollingApi.kt`):** * Introduced `PollingApi` interface defining the stable public contract for interacting with the polling functionality. * Created a `Polling` object that implements `PollingApi` and delegates calls to the internal `PollingEngine`. This is now the recommended way for apps to use the library. * `PollingEngine.Handle` is replaced by a public `PollingSession` data class, which is returned by `Polling.startPolling` and contains the session `id`. * **`PollingEngine.kt` Internalization:** * `PollingEngine` object and its `State` enum are now `internal`. * Public methods on `PollingEngine` (e.g., `startPolling`, `pollUntil`, `cancel`, `pause`, `resume`, `updateBackoff`, `cancelAll`, `shutdown`, `compose`) are now `internal` or `fun` (if they were already part of the internal API). Consumers should use the `Polling` facade. * Removed `metrics` recording calls (`recordAttempt`, `recordResult`, `recordComplete`) from `PollingEngine` as the `Metrics` interface was removed. * **Configuration and Core (`PollingConfig.kt`, `Core.kt`):** * Removed `Logger` and `Metrics` interfaces and their usage in `PollingConfig` and `PollingConfigBuilder`. Observability is now primarily through `onAttempt`, `onResult`, and `onComplete` hooks. * `ErrorCodes` object is now `internal`. * `FetchStrategy`, `SuccessStrategy`, `RetryStrategy` interfaces are now `internal`. * `DefaultRetryPredicates` object renamed to `RetryPredicates` and its `retryOnNetworkServerTimeout` predicate is renamed to `networkOrServerOrTimeout`. * The default `throwableMapper` in `PollingConfig` and `PollingConfigBuilder` now uses a stable public error code (`-1`) instead of the internal `ErrorCodes.UNKNOWN_ERROR_CODE`. * **Build & Documentation:** * Updated `group` ID in `pollingengine/build.gradle.kts` from `io.github.bosankus` to `in.androidplay`. * Updated README: * Changed artifact coordinates to `in.androidplay:pollingengine`. * Updated usage examples to use the new `Polling` facade and `RetryPredicates`. * Added a section on "Control APIs and Runtime Updates" demonstrating `Polling.startPolling`, `pause`, `resume`, `updateBackoff`, `cancel`. * Added examples for `RetryPredicates`. * Added a note about the API rename (`PollingEngineApi` -> `PollingApi`, use `Polling` facade). * Removed "Pluggable logging and metrics" from the feature list. * Added `docs/PollingEngine.md` (renamed from `composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt` which was an empty file): This new Markdown file serves as a developer guide, explaining the public API, concepts, and usage. * Updated `docs/ci-setup.md` with the new timestamp. * Updated `gradle/libs.versions.toml`: * `pollingengine` artifact path updated to `in.androidplay:pollingengine`. * `kotlinx-coroutines` version updated to `1.10.2`. * **Sample App (`App.kt`):** * Now uses the `Polling` facade instead of `PollingEngine` directly. * Uses `Polling.run` for one-shot polling instead of `PollingEngine.startPolling` with a job for simplified start/stop logic. * Removed `PollingEngine.Handle` and uses `PollingSession` (though direct handle usage is reduced due to `Polling.run`). * Pause/Resume functionality is now managed by a local `isPaused` state that controls the countdown timer, as `Polling.run` is synchronous. The actual engine pause/resume is not used in this simplified flow. * Stop functionality now cancels the `pollingJob` launched for `Polling.run`. * Added a retry strategy selector using `RetryPredicates` (`Always`, `Never`, `Network/Server/Timeout`). * Minor UI tweaks using `AnimatedVisibility` and `RoundedCornerShape`. * Round elapsed time values more consistently using `kotlin.math.round`. * **Testing (`PollingEngineCoreTests.kt`, `PollingEngineCancellationTest.kt`):** * Added `PollingEngineCoreTests.kt` with basic tests for success, non-retryable failure, max attempts, overall timeout, and `compose` behavior. * Updated `PollingEngineCancellationTest.kt` to use `Polling.run`. * **Removed Files:** * `composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt` * `composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt` * `composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt` (content moved to `docs/PollingEngine.md`) **Impact:** * Provides a clearer and more stable public API for library consumers via the `Polling` facade. * Reduces the public API surface by internalizing engine details. * Simplifies observability by focusing on callback hooks rather than pluggable interfaces for logging/metrics. * The sample app demonstrates the new facade and provides a control to switch retry strategies. --- README.md | 70 ++++- .../pollingengine/Platform.android.kt | 0 .../in/androidplay/pollingengine/App.kt | 269 +++++++++--------- .../in/androidplay/pollingengine/Greeting.kt | 0 .../in/androidplay/pollingengine/Platform.kt | 0 docs/PollingEngine.md | 160 +++++++++++ docs/ci-setup.md | 2 +- gradle/libs.versions.toml | 4 +- pollingengine/build.gradle.kts | 2 +- .../androidplay/pollingengine/polling/Core.kt | 43 ++- .../pollingengine/polling/Polling.kt | 34 +++ .../pollingengine/polling/PollingAPI.kt | 47 +++ .../pollingengine/polling/PollingConfig.kt | 5 +- .../polling/PollingConfigBuilder.kt | 53 +++- .../pollingengine/polling/PollingEngine.kt | 41 ++- .../pollingengine/polling/PollingSession.kt | 11 + .../polling/PollingEngineCancellationTest.kt | 6 +- .../polling/PollingEngineCoreTests.kt | 143 ++++++++++ 18 files changed, 665 insertions(+), 225 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt delete mode 100644 composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt create mode 100644 docs/PollingEngine.md create mode 100644 pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt create mode 100644 pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt create mode 100644 pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingSession.kt create mode 100644 pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt diff --git a/README.md b/README.md index ca63118..48f4301 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # PollingEngine -[![Maven Central](https://img.shields.io/maven-central/v/io.github.bosankus/pollingengine.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.bosankus/pollingengine) +Last updated: 2025-09-07 02:33 + +[![Maven Central](https://img.shields.io/maven-central/v/in.androidplay/pollingengine.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/in.androidplay/pollingengine) ![Kotlin](https://img.shields.io/badge/Kotlin-2.2.10-blue?logo=kotlin) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-green.svg)](#license) [![CI](https://img.shields.io/badge/CI-GitHub%20Actions-inactive.svg)](#setupbuild-instructions) @@ -12,7 +14,6 @@ with: - Timeouts (overall and per‑attempt) - Cancellation and control APIs - Observability hooks (attempt/result/complete) -- Pluggable logging and metrics Mermaid flow diagram (GitHub renders this): @@ -37,6 +38,9 @@ flowchart TD ## Project Overview +Note: Public API rename — PollingEngineApi has been renamed to PollingApi, and apps should use the +facade object `Polling` instead of referencing `PollingEngine` directly. + PollingEngine helps you repeatedly call a function until a condition is met or limits are reached. It is designed for long‑polling workflows like waiting for a server job to complete, checking payment status, etc. @@ -54,7 +58,7 @@ Highlights: Coordinates on Maven Central: -- groupId: io.github.bosankus +- groupId: in.androidplay - artifactId: pollingengine - version: 0.1.0 @@ -62,14 +66,14 @@ Gradle Kotlin DSL (Android/shared): ```kotlin repositories { mavenCentral() } -dependencies { implementation("io.github.bosankus:pollingengine:0.1.0") } +dependencies { implementation("in.androidplay:pollingengine:0.1.0") } ``` Gradle Groovy DSL: ```groovy repositories { mavenCentral() } -dependencies { implementation "io.github.bosankus:pollingengine:0.1.0" } +dependencies { implementation "in.androidplay:pollingengine:0.1.0" } ``` Maven: @@ -77,7 +81,7 @@ Maven: ```xml - io.github.bosankus + in.androidplay pollingengine 0.1.0 @@ -112,17 +116,16 @@ cd iosApp && pod install Basic shared usage: ```kotlin -import `in`.androidplay.pollingengine.models.PollingResult -import `in`.androidplay.pollingengine.polling.* val config = pollingConfig { fetch { /* return PollingResult */ TODO() } success { it == "READY" } - retry(DefaultRetryPredicates.retryOnNetworkServerTimeout) + // Retry for common transient errors (network/server/timeout/unknown) + retry(RetryPredicates.networkOrServerOrTimeout) backoff(BackoffPolicies.quick20s) } -suspend fun run(): PollingOutcome = PollingEngine.pollUntil(config) +suspend fun run(): PollingOutcome = Polling.run(config) ``` Android example (ViewModel + Compose): @@ -139,7 +142,7 @@ class StatusViewModel : ViewModel() { } fun runOnce() = viewModelScope.launch { - _status.value = PollingEngine.pollUntil(config).toString() + _status.value = Polling.run(config).toString() } } ``` @@ -212,7 +215,7 @@ Publishing to Maven Central uses com.vanniktech.maven.publish. - Required environment variables/Gradle properties (typically set in CI): - OSSRH_USERNAME, OSSRH_PASSWORD - SIGNING_KEY (Base64 GPG private key), SIGNING_PASSWORD - - GROUP: io.github.bosankus (already configured) + - GROUP: in.androidplay (already configured) - Commands: ```bash @@ -255,3 +258,46 @@ Copyright (c) 2025 AndroidPlay - Maintainer: @bosankus - Issues: use [GitHub Issues](https://github.com/bosankus/PollingEngine/issues) - Security: see [SECURITY.md](SECURITY.md) + +## Control APIs and Runtime Updates + +You can start background polling and control it via the Polling facade: + +```kotlin +val handle = Polling.startPolling(config) { outcome -> + println("Outcome: $outcome") +} + +// Pause/resume a running session +kotlinx.coroutines.GlobalScope.launch { Polling.pause(handle.id) } +kotlinx.coroutines.GlobalScope.launch { Polling.resume(handle.id) } + +// Update backoff policy at runtime +kotlinx.coroutines.GlobalScope.launch { + Polling.updateBackoff(handle.id, BackoffPolicies.quick20s) +} + +// Cancel by handle or by id +kotlinx.coroutines.GlobalScope.launch { Polling.cancel(handle) } +``` + +## RetryPredicates examples + +Built-ins to reduce boilerplate: + +```kotlin +// Retry for network/server/timeout/unknown errors (recommended) +retry(RetryPredicates.networkOrServerOrTimeout) + +// Always retry on failures +retry(RetryPredicates.always) + +// Never retry on failures +retry(RetryPredicates.never) +``` + +## More documentation + +See the full developer guide with more examples and API overview: + +- docs/PollingEngine.md diff --git a/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt b/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt deleted file mode 100644 index e69de29..0000000 diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt index cfcebf5..ae8ac29 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -1,5 +1,6 @@ package `in`.androidplay.pollingengine +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -14,9 +15,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -26,6 +29,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,15 +49,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import `in`.androidplay.pollingengine.models.PollingResult import `in`.androidplay.pollingengine.polling.BackoffPolicy -import `in`.androidplay.pollingengine.polling.PollingEngine +import `in`.androidplay.pollingengine.polling.Polling import `in`.androidplay.pollingengine.polling.PollingOutcome +import `in`.androidplay.pollingengine.polling.PollingSession +import `in`.androidplay.pollingengine.polling.RetryPredicates import `in`.androidplay.pollingengine.polling.pollingConfig +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview import kotlin.math.pow - -// ------- Models and helpers (moved above App to avoid any potential local-declaration parsing issues) ------- - +import kotlin.math.round @Composable private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { @@ -70,7 +77,7 @@ private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { Box( modifier = modifier .fillMaxWidth() - .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) .background(bg) .border(1.dp, border, androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) .padding(12.dp) @@ -82,7 +89,7 @@ private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { items(items = logs, key = { it.hashCode() }) { line -> - androidx.compose.animation.AnimatedVisibility(visible = true) { + AnimatedVisibility(visible = true) { LogEntryCard(line = line) } } @@ -215,7 +222,7 @@ private fun describeOutcome(outcome: PollingOutcome): String = when (outc is PollingOutcome.Success -> { val secs = (outcome.elapsedMs / 100L).toFloat() / 10f "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedSec=${ - ((kotlin.math.round( + ((round( secs * 10f )) / 10f) })" @@ -223,17 +230,17 @@ private fun describeOutcome(outcome: PollingOutcome): String = when (outc is PollingOutcome.Exhausted -> { val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" } is PollingOutcome.Timeout -> { val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Timeout(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + "Timeout(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" } is PollingOutcome.Cancelled -> { val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" } } @@ -260,10 +267,10 @@ private fun GlowingButton( } private fun buildTechTypography( - base: androidx.compose.material3.Typography, + base: Typography, family: FontFamily -): androidx.compose.material3.Typography { - return androidx.compose.material3.Typography( +): Typography { + return Typography( displayLarge = base.displayLarge.copy(fontFamily = family), displayMedium = base.displayMedium.copy(fontFamily = family), displaySmall = base.displaySmall.copy(fontFamily = family), @@ -291,7 +298,7 @@ fun App() { val neonPrimary = Color(0xFF00E5A8) val bg = Color(0xFF0B1015) val onBg = Color(0xFFE6F1FF) - val darkScheme = androidx.compose.material3.darkColorScheme( + val darkScheme = darkColorScheme( primary = neonPrimary, onPrimary = Color(0xFF00110A), background = bg, @@ -302,14 +309,14 @@ fun App() { onSurfaceVariant = Color(0xFFB7C4D6), outline = Color(0xFF334155), ) - val baseTypography = androidx.compose.material3.Typography() + val baseTypography = Typography() val techTypography = buildTechTypography(baseTypography, FontFamily.SansSerif) MaterialTheme(colorScheme = darkScheme, typography = techTypography) { val scope = rememberCoroutineScope() var isRunning by remember { mutableStateOf(false) } var isPaused by remember { mutableStateOf(false) } - var handle by remember { mutableStateOf(null) } + var handle by remember { mutableStateOf(null) } val logs = remember { mutableStateListOf() } var remainingMs by remember { mutableStateOf(0L) } var showProperties by remember { mutableStateOf(false) } @@ -323,6 +330,15 @@ fun App() { var overallTimeoutText by remember { mutableStateOf("30000") } var perAttemptTimeoutText by remember { mutableStateOf("") } // empty = null + // Retry strategy index + var retryStrategyIndex by remember { mutableStateOf(0) } + val retryStrategies = listOf( + "Always" to RetryPredicates.always, + "Never" to RetryPredicates.never, + "Network/Server/Timeout" to RetryPredicates.networkOrServerOrTimeout + ) + var pollingJob by remember { mutableStateOf(null) } + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Column( modifier = Modifier @@ -344,137 +360,93 @@ fun App() { // Start button + countdown Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { GlowingButton( - enabled = true, - text = when { - !isRunning -> "Start Polling" - isPaused -> "Resume" - else -> "Pause" - }, + enabled = !isRunning, + text = "Start Polling", onClick = { - fun appendLog(msg: String) { - scope.launch { logs.add(msg) } + logs.clear() + // Parse and validate inputs + val initialDelay = initialDelayText.toLongOrNull() + val maxDelay = maxDelayText.toLongOrNull() + val multiplier = multiplierText.toDoubleOrNull() + val jitter = jitterText.toDoubleOrNull() + val maxAttempts = maxAttemptsText.toIntOrNull() + val overallTimeout = overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + logs.add("[error] Invalid properties. Please enter valid numbers.") + return@GlowingButton } - if (!isRunning) { - logs.clear() - - // Parse and validate inputs - val initialDelay = initialDelayText.toLongOrNull() - val maxDelay = maxDelayText.toLongOrNull() - val multiplier = multiplierText.toDoubleOrNull() - val jitter = jitterText.toDoubleOrNull() - val maxAttempts = maxAttemptsText.toIntOrNull() - val overallTimeout = overallTimeoutText.toLongOrNull() - val perAttemptTimeout = - perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() - - if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { - appendLog("[error] Invalid properties. Please enter valid numbers.") - return@GlowingButton - } - - val backoff = try { - BackoffPolicy( - initialDelayMs = initialDelay, - maxDelayMs = maxDelay, - multiplier = multiplier, - jitterRatio = jitter, - maxAttempts = maxAttempts, - overallTimeoutMs = overallTimeout, - perAttemptTimeoutMs = perAttemptTimeout, + val backoff = BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + isRunning = true + isPaused = false + remainingMs = backoff.overallTimeoutMs + var attemptCounter = 0 + val config = pollingConfig { + fetch { + attemptCounter++ + if (attemptCounter < 8) PollingResult.Waiting else PollingResult.Success( + "Ready at attempt #$attemptCounter" ) - } catch (t: Throwable) { - appendLog("[error] ${t.message}") - return@GlowingButton } - - isRunning = true - isPaused = false - remainingMs = backoff.overallTimeoutMs - - // Sample finish logic: succeed on the 8th attempt (to show exponential logs) - var attemptCounter = 0 - - val config = pollingConfig { - fetch { - attemptCounter++ - if (attemptCounter < 8) { - PollingResult.Waiting - } else { - PollingResult.Success("Ready at attempt #$attemptCounter") - } - } - success { value -> value.isNotEmpty() } - backoff(backoff) - onAttempt { attempt, delayMs -> - val baseDelay = (backoff.initialDelayMs * - backoff.multiplier.pow((attempt - 1).toDouble()) - ).toLong().coerceAtMost(backoff.maxDelayMs) - val baseSecs = ((baseDelay) / 100L).toFloat() / 10f - val baseSecsStr = - ((kotlin.math.round(baseSecs * 10f)) / 10f).toString() - val actualDelay = delayMs ?: 0L - val actualSecs = (actualDelay / 100L).toFloat() / 10f - val actualSecsStr = - ((kotlin.math.round(actualSecs * 10f)) / 10f).toString() - appendLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") - } - onResult { attempt, result -> - appendLog( - "[info] Result at #$attempt: ${ - describeResult( - result - ) - }" - ) - } - onComplete { attempts, durationMs, outcome -> - val secs = (durationMs / 100L).toFloat() / 10f - val secsStr = - ((kotlin.math.round(secs * 10f)) / 10f).toString() - appendLog( - "[done] Completed after $attempts attempts in ${secsStr}s: ${ - describeOutcome( - outcome - ) - }" - ) - } + success { it.isNotEmpty() } + backoff(backoff) + retry(retryStrategies[retryStrategyIndex].second) + onAttempt { attempt, delayMs -> + val baseDelay = + (backoff.initialDelayMs * backoff.multiplier.pow((attempt - 1).toDouble())).toLong() + .coerceAtMost(backoff.maxDelayMs) + val baseSecs = ((baseDelay) / 100L).toFloat() / 10f + val baseSecsStr = ((round(baseSecs * 10f)) / 10f).toString() + val actualDelay = delayMs ?: 0L + val actualSecs = (actualDelay / 100L).toFloat() / 10f + val actualSecsStr = ((round(actualSecs * 10f)) / 10f).toString() + logs.add("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") } - - // Start countdown ticker respecting pause - scope.launch { - while (isRunning && remainingMs > 0) { - kotlinx.coroutines.delay(100) - if (!isPaused) remainingMs = - (remainingMs - 100).coerceAtLeast(0) - } + onResult { attempt, result -> + logs.add("[info] Result at #$attempt: ${describeResult(result)}") } - - // Start polling - handle = PollingEngine.startPolling(config) { outcome -> - appendLog("[done] Final Outcome: ${describeOutcome(outcome)}") - isRunning = false - isPaused = false - remainingMs = 0 - handle = null + onComplete { attempts, durationMs, outcome -> + val secs = (durationMs / 100L).toFloat() / 10f + val secsStr = ((round(secs * 10f)) / 10f).toString() + logs.add( + "[done] Completed after $attempts attempts in ${secsStr}s: ${ + describeOutcome( + outcome + ) + }" + ) } - } else { - // Toggle pause/resume - handle?.let { - if (isPaused) { - scope.launch { PollingEngine.resume(it.id) } - isPaused = false - } else { - scope.launch { PollingEngine.pause(it.id) } - isPaused = true - } + } + pollingJob = scope.launch { + val outcome = Polling.run(config) + logs.add("[done] Final Outcome: ${describeOutcome(outcome)}") + isRunning = false + isPaused = false + remainingMs = 0 + handle = null + } + // Start countdown ticker respecting pause + scope.launch { + while (isRunning && remainingMs > 0) { + delay(100) + if (!isPaused) remainingMs = + (remainingMs - 100).coerceAtLeast(0) } } } ) Spacer(Modifier.weight(1f)) val secs = (remainingMs / 100L).toFloat() / 10f - val secsStr = ((kotlin.math.round(secs * 10f)) / 10f).toString() + val secsStr = ((round(secs * 10f)) / 10f).toString() Text( text = if (isRunning) "${secsStr}s left" + if (isPaused) " (paused)" else "" else "", color = MaterialTheme.colorScheme.onBackground, @@ -482,13 +454,19 @@ fun App() { ) // Stop button + GlowingButton( + enabled = isRunning, + text = if (isPaused) "Resume" else "Pause", + onClick = { + isPaused = !isPaused + } + ) + Spacer(Modifier.width(8.dp)) GlowingButton( enabled = isRunning, text = "Stop", onClick = { - handle?.let { h -> - scope.launch { PollingEngine.cancel(h) } - } + pollingJob?.cancel() isRunning = false isPaused = false handle = null @@ -572,7 +550,7 @@ fun App() { } handle?.let { h -> scope.launch { - PollingEngine.updateBackoff( + Polling.updateBackoff( h.id, newPolicy ) @@ -586,6 +564,21 @@ fun App() { Spacer(Modifier.height(16.dp)) + // Retry strategy selector + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Retry Strategy:", color = MaterialTheme.colorScheme.onBackground) + Spacer(Modifier.width(8.dp)) + retryStrategies.forEachIndexed { idx, (label, _) -> + GlowingButton( + enabled = retryStrategyIndex != idx, + text = label, + onClick = { retryStrategyIndex = idx }, + ) + Spacer(Modifier.width(4.dp)) + } + } + Spacer(Modifier.height(8.dp)) + // Terminal Log view TerminalLog(modifier = Modifier.weight(1f), logs = logs) } diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt deleted file mode 100644 index e69de29..0000000 diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt deleted file mode 100644 index e69de29..0000000 diff --git a/docs/PollingEngine.md b/docs/PollingEngine.md new file mode 100644 index 0000000..e0500dc --- /dev/null +++ b/docs/PollingEngine.md @@ -0,0 +1,160 @@ +# PollingEngine — Developer Guide + +Last updated: 2025-09-07 02:33 + +This guide explains the public API exposed to app developers and how to use it from Android/iOS. The +library provides a robust polling engine with exponential backoff, retry predicates, and runtime +controls. + +Key concepts: + +- Polling facade (Polling): the single entry point apps use. +- PollingApi: the public interface implemented by Polling. +- PollingConfig DSL: declare what to fetch, how to detect success, and how to back off. +- BackoffPolicy: controls delays, jitter, attempts, and timeouts. +- Outcomes: PollingOutcome is the terminal result (Success, Exhausted, Timeout, Cancelled). + +## Quick start + +Create a config and run once (suspending): + +```kotlin +import in .androidplay.pollingengine.models.PollingResult +import in .androidplay.pollingengine.polling.* + +val config = pollingConfig { + fetch { + // Return a PollingResult based on your current state/network + // e.g. call server and map response to PollingResult + PollingResult.Waiting // or Success(data), Failure(error), Unknown, Cancelled + } + success { data -> data == "READY" } + // Choose a retry predicate (see RetryPredicates below) + retry(RetryPredicates.networkOrServerOrTimeout) + backoff(BackoffPolicies.quick20s) +} + +suspend fun runOnce(): PollingOutcome = Polling.run(config) +``` + +Start background polling with a callback and control it later: + +```kotlin +val handle = Polling.startPolling(config) { outcome -> + // Called when polling reaches a terminal outcome + println("Outcome: $outcome") +} + +// Pause/Resume +kotlinx.coroutines.GlobalScope.launch { Polling.pause(handle.id) } +kotlinx.coroutines.GlobalScope.launch { Polling.resume(handle.id) } + +// Update backoff at runtime +kotlinx.coroutines.GlobalScope.launch { + Polling.updateBackoff(handle.id, BackoffPolicies.quick20s) +} + +// Cancel +kotlinx.coroutines.GlobalScope.launch { Polling.cancel(handle) } +``` + +## API surface (stable) + +Polling facade implements the following interface: + +```kotlin +interface PollingApi { + fun activePollsCount(): Int + suspend fun listActiveIds(): List + + suspend fun cancel(id: String) + suspend fun cancel(session: in.androidplay.pollingengine.polling.PollingSession) + suspend fun cancelAll() + suspend fun shutdown() + + suspend fun pause(id: String) + suspend fun resume(id: String) + suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) + + fun startPolling( + config: PollingConfig, + onComplete: (PollingOutcome) -> Unit, + ): in.androidplay.pollingengine.polling.PollingSession + + suspend fun run(config: PollingConfig): PollingOutcome + suspend fun compose(vararg configs: PollingConfig): PollingOutcome +} +``` + +Use Polling everywhere in apps; Polling delegates to the internal PollingEngine implementation. + +## DSL overview (PollingConfig) + +```kotlin +val config = pollingConfig { + fetch { + // Do work and return a PollingResult + // Success(value), Waiting, Failure(error), Unknown, Cancelled + PollingResult.Waiting + } + success { value -> value.isComplete } + + // Retry predicate receives a domain Error? for error cases (see RetryPredicates) + retry { err -> + // Example: retry for network/server/timeout/unknown + RetryPredicates.networkOrServerOrTimeout(err) + } + + // Observability hooks + onAttempt { attempt, delayMs -> println("Attempt #$attempt, next delay=$delayMs ms") } + onResult { attempt, result -> println("Result at $attempt: $result") } + onComplete { attempts, durationMs, outcome -> println("Done in $attempts attempts: $outcome") } + + backoff( + BackoffPolicy( + initialDelayMs = 500, + maxDelayMs = 5000, + multiplier = 1.8, + jitterRatio = 0.15, + maxAttempts = 12, + overallTimeoutMs = 30_000, + perAttemptTimeoutMs = null, + ) + ) +} +``` + +### RetryPredicates + +Built-ins to reduce boilerplate: + +```kotlin +retry(RetryPredicates.networkOrServerOrTimeout) +// or +retry(RetryPredicates.always) +// or +retry(RetryPredicates.never) +``` + +## Sample app integration + +The Compose Multiplatform sample uses the Polling facade: + +- Start: Polling.startPolling(config) { outcome -> ... } +- Pause/Resume: Polling.pause(id), Polling.resume(id) +- Cancel: Polling.cancel(handle) +- Update backoff: Polling.updateBackoff(id, policy) + +See composeApp/src/commonMain/.../App.kt for a full example with UI and logs. + +## Migration notes (rename) + +- PollingEngineApi has been renamed to PollingApi. +- A dedicated facade object Polling now implements PollingApi and should be used by apps. +- Internal details remain in PollingEngine; apps should avoid direct usage and prefer Polling. + +## Reference docs + +- Generate Dokka: `./gradlew :pollingengine:dokkaHtml` +- Output: pollingengine/build/dokka/html/index.html + diff --git a/docs/ci-setup.md b/docs/ci-setup.md index 4f2717d..71805fb 100644 --- a/docs/ci-setup.md +++ b/docs/ci-setup.md @@ -3,7 +3,7 @@ Note: CI workflows and publishing configuration have been removed from this repository as part of a cleanup. This document is retained for reference only. Do not commit credentials or workflow files. -Last updated: 2025-09-05 +Last updated: 2025-09-07 02:33 This document explains how to set up Continuous Integration (CI) for this repository and how to publish the PollingEngine libraries to Maven Central (Android/KMP) and CocoaPods (iOS). It assumes you are using GitHub as the hosting platform. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76c7999..1ec7e3e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ composeMultiplatform = "1.8.2" junit = "4.13.2" kotlin = "2.2.10" pollingengine = "0.1.0" -kotlinx-coroutines = "1.9.0" +kotlinx-coroutines = "1.10.2" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -26,7 +26,7 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } -pollingengine = { module = "io.github.bosankus:pollingengine", version.ref = "pollingengine" } +pollingengine = { module = "in.androidplay:pollingengine", version.ref = "pollingengine" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } diff --git a/pollingengine/build.gradle.kts b/pollingengine/build.gradle.kts index a96d3eb..095a8fd 100644 --- a/pollingengine/build.gradle.kts +++ b/pollingengine/build.gradle.kts @@ -11,7 +11,7 @@ plugins { id("signing") } -group = "io.github.bosankus" +group = "in.androidplay" version = libs.versions.pollingengine.get() description = "PollingEngine KMP library providing robust polling with backoff and jitter" diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt index 73d7f09..8e3f874 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt @@ -3,36 +3,26 @@ package `in`.androidplay.pollingengine.polling import `in`.androidplay.pollingengine.models.Error import `in`.androidplay.pollingengine.models.PollingResult -// --- Observability contracts --- -public interface Logger { - public fun log(level: String, message: String, throwable: Throwable? = null): Unit -} - -public interface Metrics { - public fun recordAttempt(attempt: Int, delayMs: Long?): Unit {} - public fun recordResult(attempt: Int, result: PollingResult<*>): Unit {} - public fun recordComplete(attempts: Int, durationMs: Long): Unit {} -} // --- Internal error codes to decouple from external sources --- -public object ErrorCodes { - public const val UNKNOWN_ERROR_CODE: Int = -1 - public const val NETWORK_ERROR: Int = 1001 - public const val SERVER_ERROR_CODE: Int = 500 - public const val TIMEOUT_ERROR_CODE: Int = 1002 +internal object ErrorCodes { + internal const val UNKNOWN_ERROR_CODE: Int = -1 + internal const val NETWORK_ERROR: Int = 1001 + internal const val SERVER_ERROR_CODE: Int = 500 + internal const val TIMEOUT_ERROR_CODE: Int = 1002 } // --- Strategies --- -public interface FetchStrategy { - public suspend fun fetch(): PollingResult +internal interface FetchStrategy { + suspend fun fetch(): PollingResult } -public interface SuccessStrategy { - public fun isTerminal(value: T): Boolean +internal interface SuccessStrategy { + fun isTerminal(value: T): Boolean } -public interface RetryStrategy { - public fun shouldRetry(error: Error?): Boolean +internal interface RetryStrategy { + fun shouldRetry(error: Error?): Boolean } internal class LambdaFetchStrategy(private val block: suspend () -> PollingResult) : FetchStrategy { @@ -47,12 +37,15 @@ internal class LambdaRetryStrategy(private val predicate: (Error?) -> Boolean) : override fun shouldRetry(error: Error?): Boolean = predicate(error) } -// --- Default retry predicates --- -public object DefaultRetryPredicates { +/** + * Common retry strategies for polling. Use these to avoid boilerplate and ensure consistency. + */ +public object RetryPredicates { /** - * Retry for network-related, server and timeout errors; also retries unknowns by default. + * Retry for network-related, server, timeout, and unknown errors. + * Recommended for most network polling scenarios. */ - public val retryOnNetworkServerTimeout: (Error?) -> Boolean = { err -> + public val networkOrServerOrTimeout: (Error?) -> Boolean = { err -> when (err?.code) { ErrorCodes.NETWORK_ERROR, ErrorCodes.SERVER_ERROR_CODE, ErrorCodes.TIMEOUT_ERROR_CODE, ErrorCodes.UNKNOWN_ERROR_CODE -> true else -> false diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt new file mode 100644 index 0000000..bab47ef --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt @@ -0,0 +1,34 @@ +package `in`.androidplay.pollingengine.polling + +/** + * Public facade instance for consumers. Delegates to the internal engine. + * + * This is the single entry-point to start, control, and run polling operations. + */ +public object Polling : PollingApi { + override fun activePollsCount(): Int = PollingEngine.activePollsCount() + override suspend fun listActiveIds(): List = PollingEngine.listActiveIds() + + override suspend fun cancel(id: String): Unit = PollingEngine.cancel(id) + override suspend fun cancel(session: PollingSession): Unit = PollingEngine.cancel(session.id) + override suspend fun cancelAll(): Unit = PollingEngine.cancelAll() + override suspend fun shutdown(): Unit = PollingEngine.shutdown() + + override suspend fun pause(id: String): Unit = PollingEngine.pause(id) + override suspend fun resume(id: String): Unit = PollingEngine.resume(id) + override suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy): Unit = + PollingEngine.updateBackoff(id, newPolicy) + + override fun startPolling( + config: PollingConfig, + onComplete: (PollingOutcome) -> Unit, + ): PollingSession = + PollingEngine.startPolling(config, onComplete).let { PollingSession(it.id) } + + override suspend fun run(config: PollingConfig): PollingOutcome = + PollingEngine.pollUntil(config) + + override suspend fun compose(vararg configs: PollingConfig): PollingOutcome = + PollingEngine.compose(*configs) +} + diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt new file mode 100644 index 0000000..ab4482e --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt @@ -0,0 +1,47 @@ +package `in`.androidplay.pollingengine.polling + +/** + * Public abstraction layer for consumers. Exposes a small, stable API surface. + * Internals are delegated to the PollingEngine implementation. + */ +public interface PollingApi { + /** Number of active polling sessions. */ + public fun activePollsCount(): Int + + /** IDs of active polling sessions. */ + public suspend fun listActiveIds(): List + + /** Cancel a session by ID. */ + public suspend fun cancel(id: String) + + /** Cancel a session using the session handle. */ + public suspend fun cancel(session: PollingSession) + + /** Cancel all active sessions. */ + public suspend fun cancelAll() + + /** Shutdown the SDK's internal engine. After shutdown, new sessions cannot be started. */ + public suspend fun shutdown() + + /** Pause a session by ID. */ + public suspend fun pause(id: String) + + /** Resume a session by ID. */ + public suspend fun resume(id: String) + + /** Update backoff/options for a running session by ID. */ + public suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) + + /** Start a new polling session. Returns a lightweight [PollingSession] handle. */ + public fun startPolling( + config: PollingConfig, + onComplete: (PollingOutcome) -> Unit, + ): PollingSession + + /** One-shot polling that runs to completion synchronously (suspending). */ + public suspend fun run(config: PollingConfig): PollingOutcome + + /** Compose multiple polling configs sequentially. */ + public suspend fun compose(vararg configs: PollingConfig): PollingOutcome +} + diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt index d85bc98..f4c05f8 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt @@ -20,10 +20,9 @@ public data class PollingConfig( /** Maps any thrown exception into a domain [Error] used by retry predicates and reporting. */ val throwableMapper: (Throwable) -> Error = { t -> val msg = t.message ?: (t::class.simpleName ?: "Throwable") - Error(ErrorCodes.UNKNOWN_ERROR_CODE, msg) + // Use a stable public default code without exposing internal ErrorCodes + Error(-1, msg) }, - val logger: Logger? = null, - val metrics: Metrics? = null, ) { init { requireNotNull(fetch) { "fetch must not be null" } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt index 5f0462b..93eaa8b 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt @@ -21,63 +21,88 @@ public class PollingConfigBuilder { { _, _, _ -> } private var throwableMapper: (Throwable) -> Error = { t -> val msg = t.message ?: (t::class.simpleName ?: "Throwable") - Error(ErrorCodes.UNKNOWN_ERROR_CODE, msg) + Error(-1, msg) } - private var logger: Logger? = null - private var metrics: Metrics? = null + /** + * Sets the suspending fetch operation that produces a PollingResult per attempt. + * Typical implementation performs I/O and maps responses/errors to domain results. + */ public fun fetch(block: suspend () -> PollingResult): PollingConfigBuilder = apply { this.fetchStrategy = LambdaFetchStrategy(block) } - public fun fetch(strategy: FetchStrategy): PollingConfigBuilder = apply { + internal fun fetch(strategy: FetchStrategy): PollingConfigBuilder = apply { this.fetchStrategy = strategy } + /** + * Sets the terminal success predicate. When it returns true for a Success(value), + * polling stops with PollingOutcome.Success. + */ public fun success(predicate: (T) -> Boolean): PollingConfigBuilder = apply { this.successStrategy = LambdaSuccessStrategy(predicate) } - public fun success(strategy: SuccessStrategy): PollingConfigBuilder = apply { + internal fun success(strategy: SuccessStrategy): PollingConfigBuilder = apply { this.successStrategy = strategy } + /** + * Sets the retry predicate used when the last attempt produced a Failure(Error). + * Return true to retry, false to stop with Exhausted. + * See built-ins in [RetryPredicates]. + */ public fun retry(predicate: (Error?) -> Boolean): PollingConfigBuilder = apply { this.retryStrategy = LambdaRetryStrategy(predicate) } - public fun retry(strategy: RetryStrategy): PollingConfigBuilder = apply { + internal fun retry(strategy: RetryStrategy): PollingConfigBuilder = apply { this.retryStrategy = strategy } + /** + * Sets the backoff policy controlling delays, jitter, attempts, and timeouts. + */ public fun backoff(policy: BackoffPolicy): PollingConfigBuilder = apply { this.backoffPolicy = policy } + /** + * Sets the CoroutineDispatcher used to run polling. + * Defaults to Dispatchers.Default. + */ public fun dispatcher(dispatcher: CoroutineDispatcher): PollingConfigBuilder = apply { this.dispatcher = dispatcher } + /** + * Sets a mapper to convert thrown exceptions into domain Error values. + * Defaults to a mapper that uses code=-1 and Throwable.message/class name. + */ public fun throwableMapper(mapper: (Throwable) -> Error): PollingConfigBuilder = apply { this.throwableMapper = mapper } - public fun logger(logger: Logger?): PollingConfigBuilder = apply { - this.logger = logger - } - - public fun metrics(metrics: Metrics?): PollingConfigBuilder = apply { - this.metrics = metrics - } + /** + * Hook invoked before each attempt is executed, providing the attempt index and the + * computed delay for the upcoming attempt (null for immediate). + */ public fun onAttempt(hook: (attempt: Int, delayMs: Long?) -> Unit): PollingConfigBuilder = apply { this.onAttemptHook = hook } + /** + * Hook invoked after each attempt completes with a result (Success/Waiting/Failure/Unknown/Cancelled). + */ public fun onResult(hook: (attempt: Int, result: PollingResult) -> Unit): PollingConfigBuilder = apply { this.onResultHook = hook } + /** + * Hook invoked once with the terminal outcome, total attempts, and elapsed time. + */ public fun onComplete(hook: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit): PollingConfigBuilder = apply { this.onCompleteHook = hook @@ -97,8 +122,6 @@ public class PollingConfigBuilder { onResult = onResultHook, onComplete = onCompleteHook, throwableMapper = throwableMapper, - logger = logger, - metrics = metrics, ) } } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt index 54e3cff..1c656da 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt @@ -28,11 +28,11 @@ import kotlin.time.TimeSource * - Coroutine-friendly, supports cancellation. * - Robust handling for rogue CancellationException (treat as retryable error if scope is still active). * - Configurable via [PollingConfig] and [BackoffPolicy]. - * - Observability hooks and metrics (optional). + * - Observability hooks (attempt/result/complete). */ -public object PollingEngine { +internal object PollingEngine { - public enum class State { Running, Paused } + internal enum class State { Running, Paused } private data class Control( val id: String, @@ -44,7 +44,7 @@ public object PollingEngine { ), ) - public data class Handle(public val id: String) + internal data class Handle(internal val id: String) private var supervisor: Job = SupervisorJob() private var scope: CoroutineScope = CoroutineScope(supervisor + Dispatchers.Default) @@ -53,39 +53,39 @@ public object PollingEngine { private val controls: MutableMap = mutableMapOf() private var isShutdown: Boolean = false - public fun activePollsCount(): Int = active.size + fun activePollsCount(): Int = active.size - public suspend fun listActiveIds(): List = mutex.withLock { active.keys.toList() } + suspend fun listActiveIds(): List = mutex.withLock { active.keys.toList() } - public suspend fun cancel(id: String) { + suspend fun cancel(id: String) { mutex.withLock { active[id]?.cancel(CancellationException("Cancelled by user")) } } - public suspend fun pause(id: String) { + suspend fun pause(id: String) { mutex.withLock { controls[id]?.state?.value = State.Paused } } - public suspend fun resume(id: String) { + suspend fun resume(id: String) { mutex.withLock { controls[id]?.state?.value = State.Running } } - public suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) { + suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) { mutex.withLock { controls[id]?.backoff?.value = newPolicy } } - public suspend fun cancel(handle: Handle) { + suspend fun cancel(handle: Handle) { cancel(handle.id) } /** Cancels all active polls and clears the registry. */ - public suspend fun cancelAll() { + suspend fun cancelAll() { val toCancel: List = mutex.withLock { active.values.toList() } toCancel.forEach { it.cancel(CancellationException("Cancelled by user")) } mutex.withLock { active.clear() } } /** Shuts down the engine: cancels all polls, cancels its scope, and prevents new polls from starting. */ - public suspend fun shutdown() { + suspend fun shutdown() { if (isShutdown) return cancelAll() mutex.withLock { @@ -94,7 +94,7 @@ public object PollingEngine { supervisor.cancel(CancellationException("PollingEngine shutdown")) } - public fun startPolling( + fun startPolling( config: PollingConfig, onComplete: (PollingOutcome) -> Unit, ): Handle { @@ -125,7 +125,7 @@ public object PollingEngine { * Compose multiple polling operations sequentially. Stops early on non-success outcomes. * Returns the last outcome (success from the last config or the first non-success). */ - public suspend fun compose(vararg configs: PollingConfig): PollingOutcome { + suspend fun compose(vararg configs: PollingConfig): PollingOutcome { var lastOutcome: PollingOutcome? = null for (cfg in configs) { val control = Control(generateId()) @@ -144,7 +144,7 @@ public object PollingEngine { return buildString(10) { repeat(10) { append(alphabet.random()) } } } - public suspend fun pollUntil(config: PollingConfig): PollingOutcome = + internal suspend fun pollUntil(config: PollingConfig): PollingOutcome = pollUntil(config, Control(generateId())) private suspend fun pollUntil( @@ -180,7 +180,6 @@ public object PollingEngine { withTimeout(minOf(timeoutMs, remainingOverall)) { // Preface only the first attempt immediately if (attempt == 1) { - config.metrics?.recordAttempt(attempt, 0) config.onAttempt(attempt, 0) } config.fetch() @@ -188,7 +187,6 @@ public object PollingEngine { } else { // Preface only the first attempt immediately if (attempt == 1) { - config.metrics?.recordAttempt(attempt, 0) config.onAttempt(attempt, 0) } config.fetch() @@ -201,7 +199,6 @@ public object PollingEngine { Failure(config.throwableMapper(t)) } - config.metrics?.recordResult(attempt, result) config.onResult(attempt, result) lastResult = result @@ -211,7 +208,6 @@ public object PollingEngine { if (config.isTerminalSuccess(v)) { val totalMs = startMark.elapsedNow().inWholeMilliseconds val outcome = PollingOutcome.Success(v, attempt, totalMs) - config.metrics?.recordComplete(attempt, totalMs) config.onComplete(attempt, totalMs, outcome) return@withContext outcome } @@ -223,8 +219,6 @@ public object PollingEngine { val totalMs = startMark.elapsedNow().inWholeMilliseconds val outcome = PollingOutcome.Exhausted(result, attempt, totalMs) @Suppress("UNCHECKED_CAST") - config.metrics?.recordComplete(attempt, totalMs) - @Suppress("UNCHECKED_CAST") config.onComplete(attempt, totalMs, outcome as PollingOutcome) @Suppress("UNCHECKED_CAST") return@withContext (outcome as PollingOutcome) @@ -234,7 +228,6 @@ public object PollingEngine { is Cancelled -> { val totalMs = startMark.elapsedNow().inWholeMilliseconds val outcome = PollingOutcome.Cancelled(attempt, totalMs) - config.metrics?.recordComplete(attempt, totalMs) config.onComplete(attempt, totalMs, outcome) return@withContext outcome } @@ -261,7 +254,6 @@ public object PollingEngine { // Only announce if there is time left to sleep and another attempt could happen. val nextAttemptIndex = attempt + 1 if (nextAttemptIndex <= policy.maxAttempts) { - config.metrics?.recordAttempt(nextAttemptIndex, sleepMs) config.onAttempt(nextAttemptIndex, sleepMs) } @@ -275,7 +267,6 @@ public object PollingEngine { } else { PollingOutcome.Exhausted(lastResult, attempt, totalMs) } - config.metrics?.recordComplete(attempt, totalMs) config.onComplete(attempt, totalMs, outcome) outcome } finally { diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingSession.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingSession.kt new file mode 100644 index 0000000..7e627f8 --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingSession.kt @@ -0,0 +1,11 @@ +package `in`.androidplay.pollingengine.polling + +/** + * Represents a running polling session created by the SDK. + * + * Consumers receive this minimal handle when starting a poll and can use its [id] + * with the facade API to pause, resume, cancel, or update options. + * + * Note: Business logic and engine internals are intentionally hidden. + */ +public data class PollingSession(val id: String) \ No newline at end of file diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt index 193b0b8..0b245b3 100644 --- a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt @@ -1,9 +1,9 @@ package `in`.androidplay.pollingengine.polling -import kotlin.test.Test -import kotlin.test.assertTrue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue class PollingEngineCancellationTest { @Test @@ -26,7 +26,7 @@ class PollingEngineCancellationTest { ) ) - val outcome = PollingEngine.pollUntil(config) + val outcome = Polling.run(config) assertTrue(outcome is PollingOutcome.Exhausted, "Expected Exhausted outcome when CancellationException is thrown under active scope, but was: $outcome") } } diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt new file mode 100644 index 0000000..6cb97c1 --- /dev/null +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt @@ -0,0 +1,143 @@ +package `in`.androidplay.pollingengine.polling + +import `in`.androidplay.pollingengine.models.Error +import `in`.androidplay.pollingengine.models.PollingResult.Failure +import `in`.androidplay.pollingengine.models.PollingResult.Success +import `in`.androidplay.pollingengine.models.PollingResult.Waiting +import kotlinx.coroutines.test.runTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PollingEngineCoreTests { + + @Test + fun successStopsWhenTerminalReached() = runTest { + var calls = 0 + val config = PollingConfig( + fetch = { + calls++ + if (calls < 3) Success(calls) else Success(100) + }, + isTerminalSuccess = { it == 100 }, + backoff = BackoffPolicy( + initialDelayMs = 0, + maxDelayMs = 1, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 10, + overallTimeoutMs = 5_000, + random = Random(0) + ) + ) + + val outcome = Polling.run(config) + assertTrue(outcome is PollingOutcome.Success) + outcome as PollingOutcome.Success + assertEquals(100, outcome.value) + assertEquals(3, outcome.attempts) + } + + @Test + fun nonRetryableFailureEndsAsExhausted() = runTest { + val config = PollingConfig( + fetch = { Failure(Error(999, "boom")) }, + isTerminalSuccess = { false }, + shouldRetryOnError = { false }, + backoff = BackoffPolicy( + initialDelayMs = 0, + maxDelayMs = 1, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 5, + overallTimeoutMs = 5_000 + ) + ) + + val outcome = Polling.run(config) + assertTrue(outcome is PollingOutcome.Exhausted) + outcome + assertTrue(outcome.last is Failure) + assertEquals(1, outcome.attempts) + } + + @Test + fun retriesUntilMaxAttemptsWhenRetryable() = runTest { + var attempts = 0 + val config = PollingConfig( + fetch = { + attempts++ + Failure(Error(1001, "network")) + }, + isTerminalSuccess = { false }, + shouldRetryOnError = { true }, + backoff = BackoffPolicy( + initialDelayMs = 0, + maxDelayMs = 1, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 3, + overallTimeoutMs = 5_000 + ) + ) + val outcome = Polling.run(config) + assertTrue(outcome is PollingOutcome.Exhausted) + assertEquals(3, (outcome as PollingOutcome.Exhausted).attempts) + } + + @Test + fun overallTimeoutLeadsToTimeoutOutcome() = runTest { + var calls = 0 + val config = PollingConfig( + fetch = { + calls++ + // cause long waits by using Waiting + Waiting + }, + isTerminalSuccess = { false }, + backoff = BackoffPolicy( + initialDelayMs = 10, + maxDelayMs = 10, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 100, + overallTimeoutMs = 5 // very small overall timeout + ) + ) + val outcome = Polling.run(config) + assertTrue(outcome is PollingOutcome.Timeout, "Expected Timeout but was $outcome") + } + + @Test + fun composeStopsOnFirstNonSuccess() = runTest { + val cfg1 = PollingConfig( + fetch = { Success(1) }, + isTerminalSuccess = { true }, + backoff = BackoffPolicy( + initialDelayMs = 0, + maxDelayMs = 1, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 1, + overallTimeoutMs = 100 + ) + ) + val cfg2 = PollingConfig( + fetch = { Failure(Error(1, "x")) }, + isTerminalSuccess = { true }, + shouldRetryOnError = { false }, + backoff = BackoffPolicy( + initialDelayMs = 0, + maxDelayMs = 1, + multiplier = 1.0, + jitterRatio = 0.0, + maxAttempts = 3, + overallTimeoutMs = 100 + ) + ) + val outcome = Polling.compose(cfg1, cfg2) + assertTrue(outcome is PollingOutcome.Exhausted) + assertEquals(1, (outcome as PollingOutcome.Exhausted).attempts) + } +} From c8d87c5a7e32ec97227c21b059094d083d0bd366 Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Mon, 8 Sep 2025 18:03:40 +0530 Subject: [PATCH 2/4] refactor: Migrate sample app to ViewModel and PollingEngine to Flow This commit introduces a major refactoring of the `PollingEngine` and its sample application. The engine's `startPolling` method now returns a `Flow>` instead of taking a callback, and the sample app is restructured around a `PollingViewModel`. **Key Changes:** * **`PollingEngine` (`PollingEngine.kt`, `Polling.kt`, `PollingAPI.kt`):** * `startPolling` now returns `Flow>`. * The Flow emits the final `PollingOutcome` and then completes. * Internal management of active polls uses `channelFlow` and `awaitClose` for proper cancellation. * A new overload for `startPolling` that accepts a `PollingConfigBuilder.() -> Unit` lambda is added for convenience. * The `Handle` class is removed as Flow subscription management replaces its role. * Minor adjustment: The pause check (`control.state.map { it == State.Running }.first { it }`) is moved after the `onAttempt` callback and before `delay()`, ensuring `onAttempt` is called even if immediately paused. * **Sample App (`App.kt`, `PollingViewModel.kt`, `Theme.kt`):** * **ViewModel Architecture:** * Introduced `PollingViewModel.kt` to manage UI state (`PollingUiState`), handle user intents (`PollingIntent`), and interact with the `PollingEngine`. * `App.kt` now observes `StateFlow` from the ViewModel and dispatches intents. * **State Management:** * `PollingUiState` holds all UI-related data (running/paused state, logs, form inputs). * Logs are now `List` with unique IDs for better `LazyColumn` performance. * Uses `kotlinx.atomicfu.atomic` for unique log item IDs. * **UI Enhancements (`App.kt`):** * The main screen is now a `LazyColumn` for better scroll performance and structure. * `ControlPanel.kt` (extracted from `App.kt`): A new composable for start/pause/resume/stop buttons and countdown timer. * Properties section (`PropertiesCard` in previous commits, now inline in `App.kt` under "Basic Setup"): * Layout improved with section headers (`SectionHeader`) and dividers (`SectionDivider`). * `LabeledField` now supports `placeholder`, `suffix`, `supportingText`, `isError`, and `KeyboardType`. * Retry strategy selection uses `Button` with visual distinction for the selected item. * The "Apply Backoff at Runtime" button is moved inside the properties panel. * **Theming (`Theme.kt`):** * Extracted `MaterialTheme` setup into `PollingEngineTheme` in `Theme.kt`. * **Dependencies (`composeApp/build.gradle.kts`, `gradle/libs.versions.toml`):** * Added `kotlinx-atomicfu` dependency for `PollingViewModel`. * **Documentation (`README.md`, `docs/`):** * Removed `docs/ci-setup.md` and `docs/PollingEngine.md` as they were marked ARCHIVED or superseded. * `README.md`: Updated links to point to new `docs/pollingengine.md` and `docs/DeveloperGuide.md`. * **Build & Configuration:** * `pollingengine/publishing.gradle.kts`: Minor formatting and null-check adjustments. * Minor code style cleanups across various files (e.g., trailing commas, import organization). * **New Builder DSL (`pollingengine/polling/builder/PollingConfigBuilder.kt`):** * Introduced `PollingConfigBuilder` with `@PollingBuilderMarker` DSL for creating `PollingConfig` instances more fluently. * The `Polling.startPolling` facade now has an overload accepting this builder. **Impact:** * The `PollingEngine` API is now more idiomatic for Kotlin Coroutines users, leveraging `Flow`. * The sample application is more robust, maintainable, and demonstrates a cleaner architecture using a ViewModel. * The UI of the sample app is more organized and user-friendly. --- README.md | 53 +- composeApp/build.gradle.kts | 6 +- .../in/androidplay/pollingengine/App.kt | 789 ++++++++---------- .../pollingengine/PollingViewModel.kt | 298 +++++++ .../in/androidplay/pollingengine/Theme.kt | 61 ++ docs/PollingEngine.md | 160 ---- docs/ci-setup.md | 183 ---- gradle/libs.versions.toml | 4 +- pollingengine/build.gradle.kts | 6 +- pollingengine/pollingengine.podspec | 2 +- pollingengine/publishing.gradle.kts | 66 +- .../pollingengine/models/PollingResult.kt | 12 +- .../pollingengine/polling/BackoffPolicies.kt | 1 - .../androidplay/pollingengine/polling/Core.kt | 45 +- .../pollingengine/polling/Polling.kt | 17 +- .../pollingengine/polling/PollingAPI.kt | 14 +- .../pollingengine/polling/PollingEngine.kt | 33 +- .../polling/builder/PollingConfigBuilder.kt | 45 + .../polling/BackoffPolicyTest.kt | 41 +- .../polling/PollingEngineCancellationTest.kt | 2 +- .../polling/PollingEngineCoreTests.kt | 11 +- 21 files changed, 959 insertions(+), 890 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Theme.kt delete mode 100644 docs/PollingEngine.md delete mode 100644 docs/ci-setup.md create mode 100644 pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/builder/PollingConfigBuilder.kt diff --git a/README.md b/README.md index 48f4301..a1e1dc3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PollingEngine -Last updated: 2025-09-07 02:33 +Last updated: 2025-09-08 17:36 [![Maven Central](https://img.shields.io/maven-central/v/in.androidplay/pollingengine.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/in.androidplay/pollingengine) ![Kotlin](https://img.shields.io/badge/Kotlin-2.2.10-blue?logo=kotlin) @@ -51,7 +51,7 @@ Highlights: - Simple DSL with pollingConfig { … } - Backoff presets (e.g., BackoffPolicies.quick20s) -- Control operations: pause(id), resume(id), cancel(handle/id), cancelAll(), shutdown() +- Control operations: pause(id), resume(id), cancel(id), cancelAll(), shutdown() - Domain‑level results via PollingResult and terminal PollingOutcome ## Installation and Dependency @@ -60,20 +60,20 @@ Coordinates on Maven Central: - groupId: in.androidplay - artifactId: pollingengine -- version: 0.1.0 +- version: 0.1.1 Gradle Kotlin DSL (Android/shared): ```kotlin repositories { mavenCentral() } -dependencies { implementation("in.androidplay:pollingengine:0.1.0") } +dependencies { implementation("in.androidplay:pollingengine:0.1.1") } ``` Gradle Groovy DSL: ```groovy repositories { mavenCentral() } -dependencies { implementation "in.androidplay:pollingengine:0.1.0" } +dependencies { implementation "in.androidplay:pollingengine:0.1.1" } ``` Maven: @@ -83,7 +83,7 @@ Maven: in.androidplay pollingengine - 0.1.0 + 0.1.1 ``` @@ -261,24 +261,38 @@ Copyright (c) 2025 AndroidPlay ## Control APIs and Runtime Updates -You can start background polling and control it via the Polling facade: +Start polling by collecting the returned Flow, and control active sessions by ID: ```kotlin -val handle = Polling.startPolling(config) { outcome -> +// Start and collect in your scope +val flow = Polling.startPolling(config) +val job = flow.onEach { outcome -> println("Outcome: $outcome") -} +}.launchIn(scope) + +// Introspection +val ids = Polling.listActiveIds() // suspend; returns List +println("Active: $ids (count=${Polling.activePollsCount()})") + +// Pause/resume first active session (example) +if (ids.isNotEmpty()) { + val id = ids.first() + Polling.pause(id) + // ... later + Polling.resume(id) -// Pause/resume a running session -kotlinx.coroutines.GlobalScope.launch { Polling.pause(handle.id) } -kotlinx.coroutines.GlobalScope.launch { Polling.resume(handle.id) } + // Update backoff at runtime + Polling.updateBackoff(id, BackoffPolicies.quick20s) -// Update backoff policy at runtime -kotlinx.coroutines.GlobalScope.launch { - Polling.updateBackoff(handle.id, BackoffPolicies.quick20s) + // Cancel + Polling.cancel(id) } -// Cancel by handle or by id -kotlinx.coroutines.GlobalScope.launch { Polling.cancel(handle) } +// Or cancel all +Polling.cancelAll() + +// Stop collecting if needed +job.cancel() ``` ## RetryPredicates examples @@ -298,6 +312,5 @@ retry(RetryPredicates.never) ## More documentation -See the full developer guide with more examples and API overview: - -- docs/PollingEngine.md +- docs/pollingengine.md — Web Guide (overview, install, Android/iOS usage) +- docs/DeveloperGuide.md — Developer Guide (API overview, DSL, migration, reference) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6ab57b0..bc45f35 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -13,7 +13,7 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } - + listOf( iosArm64(), iosSimulatorArm64() @@ -23,7 +23,7 @@ kotlin { isStatic = true } } - + sourceSets { androidMain.dependencies { implementation(compose.preview) @@ -31,7 +31,6 @@ kotlin { } commonMain.dependencies { implementation(project(":pollingengine")) - //implementation(libs.pollingengine) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -41,6 +40,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.coroutines.core) + implementation(libs.atomicfu) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt index ae8ac29..62254d2 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -1,9 +1,7 @@ package `in`.androidplay.pollingengine -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,12 +13,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DividerDefaults @@ -29,76 +26,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import `in`.androidplay.pollingengine.models.PollingResult -import `in`.androidplay.pollingengine.polling.BackoffPolicy -import `in`.androidplay.pollingengine.polling.Polling -import `in`.androidplay.pollingengine.polling.PollingOutcome -import `in`.androidplay.pollingengine.polling.PollingSession -import `in`.androidplay.pollingengine.polling.RetryPredicates -import `in`.androidplay.pollingengine.polling.pollingConfig -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview -import kotlin.math.pow import kotlin.math.round -@Composable -private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { - val bg = Color(0xFF0F1115) - val border = Color(0xFF2A2F3A) - - val listState = rememberLazyListState() - - LaunchedEffect(logs.size) { - if (logs.isNotEmpty()) { - listState.animateScrollToItem(logs.lastIndex.coerceAtLeast(0)) - } - } - Box( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(bg) - .border(1.dp, border, androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) - .padding(12.dp) - ) { - SelectionContainer { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) - ) { - items(items = logs, key = { it.hashCode() }) { line -> - AnimatedVisibility(visible = true) { - LogEntryCard(line = line) - } - } - } - } - } -} - -// --- Simple log entry card without icons for multiplatform compatibility --- @Composable private fun LogEntryCard(line: String, modifier: Modifier = Modifier) { val (bgColor, textColor) = when { @@ -131,117 +74,67 @@ private fun LogEntryCard(line: String, modifier: Modifier = Modifier) { } @Composable -private fun PropertiesCard( - initialDelayText: String, onInitialChange: (String) -> Unit, - maxDelayText: String, onMaxDelayChange: (String) -> Unit, - multiplierText: String, onMultiplierChange: (String) -> Unit, - jitterText: String, onJitterChange: (String) -> Unit, - maxAttemptsText: String, onMaxAttemptsChange: (String) -> Unit, - overallTimeoutText: String, onOverallTimeoutChange: (String) -> Unit, - perAttemptTimeoutText: String, onPerAttemptTimeoutChange: (String) -> Unit, +private fun LabeledField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + suffix: String? = null, + supportingText: String? = null, + isError: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, ) { - Surface( - tonalElevation = 3.dp, - shadowElevation = 2.dp, - shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 2.dp) - ) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { + Column(modifier = modifier.fillMaxWidth()) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + label = { Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) }, + placeholder = { if (placeholder != null) Text(placeholder) }, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + trailingIcon = { + if (suffix != null) { Text( - "Delays & Attempts", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 2.dp) - ) - LabeledField("initialDelayMs", initialDelayText, onInitialChange) - LabeledField("maxAttempts", maxAttemptsText, onMaxAttemptsChange) - LabeledField( - "perAttemptTimeoutMs", - perAttemptTimeoutText, - onPerAttemptTimeoutChange + suffix, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp ) } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Text( - "Backoff & Timeouts", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 2.dp) - ) - LabeledField("maxDelayMs", maxDelayText, onMaxDelayChange) - LabeledField("multiplier", multiplierText, onMultiplierChange) - LabeledField("jitterRatio", jitterText, onJitterChange) - LabeledField("overallTimeoutMs", overallTimeoutText, onOverallTimeoutChange) - } - } + }, + isError = isError, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + modifier = Modifier.fillMaxWidth() + ) + if (supportingText != null) { + Text( + text = supportingText, + color = if (isError) Color(0xFFFF8A80) else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 12.dp, top = 4.dp) + ) } } } @Composable -private fun LabeledField(label: String, value: String, onValueChange: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - label = { Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) }, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - modifier = Modifier.fillMaxWidth() +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 2.dp) ) } -private fun describeResult(result: PollingResult): String = when (result) { - is PollingResult.Success -> "Success(${result.data})" - is PollingResult.Failure -> "Failure(code=${result.error.code}, msg=${result.error.message})" - is PollingResult.Waiting -> "Waiting" - is PollingResult.Cancelled -> "Cancelled" - is PollingResult.Unknown -> "Unknown" -} - -private fun describeOutcome(outcome: PollingOutcome): String = when (outcome) { - is PollingOutcome.Success -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedSec=${ - ((round( - secs * 10f - )) / 10f) - })" - } - - is PollingOutcome.Exhausted -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" - } - - is PollingOutcome.Timeout -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Timeout(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" - } - - is PollingOutcome.Cancelled -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" - } +@Composable +private fun SectionDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) } @Composable @@ -266,27 +159,63 @@ private fun GlowingButton( } } -private fun buildTechTypography( - base: Typography, - family: FontFamily -): Typography { - return Typography( - displayLarge = base.displayLarge.copy(fontFamily = family), - displayMedium = base.displayMedium.copy(fontFamily = family), - displaySmall = base.displaySmall.copy(fontFamily = family), - headlineLarge = base.headlineLarge.copy(fontFamily = family), - headlineMedium = base.headlineMedium.copy(fontFamily = family), - headlineSmall = base.headlineSmall.copy(fontFamily = family), - titleLarge = base.titleLarge.copy(fontFamily = family), - titleMedium = base.titleMedium.copy(fontFamily = family), - titleSmall = base.titleSmall.copy(fontFamily = family), - bodyLarge = base.bodyLarge.copy(fontFamily = family), - bodyMedium = base.bodyMedium.copy(fontFamily = family), - bodySmall = base.bodySmall.copy(fontFamily = family), - labelLarge = base.labelLarge.copy(fontFamily = family), - labelMedium = base.labelMedium.copy(fontFamily = family), - labelSmall = base.labelSmall.copy(fontFamily = family), - ) +@Composable +private fun ControlPanel( + isRunning: Boolean, + isPaused: Boolean, + remainingMs: Long, + onStart: () -> Unit, + onPause: () -> Unit, + onStop: () -> Unit +) { + val secs = (remainingMs / 100L).toFloat() / 10f + val secsStr = ((round(secs * 10f)) / 10f).toString() + Surface( + tonalElevation = 3.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) { + Column( + modifier = Modifier.padding(18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = if (isRunning) "${secsStr}s left" + if (isPaused) " (paused)" else "" else "Not running", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleLarge + ) + if (isRunning) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + GlowingButton( + enabled = true, + text = if (isPaused) "Resume" else "Pause", + onClick = onPause + ) + GlowingButton( + enabled = true, + text = "Stop", + onClick = onStop + ) + } + } else { + GlowingButton( + enabled = true, + text = "Start Polling", + onClick = onStart + ) + } + } + } } // ------- Main App ------- @@ -294,294 +223,258 @@ private fun buildTechTypography( @Composable @Preview fun App() { - // Global advanced dark theme with custom typography - val neonPrimary = Color(0xFF00E5A8) - val bg = Color(0xFF0B1015) - val onBg = Color(0xFFE6F1FF) - val darkScheme = darkColorScheme( - primary = neonPrimary, - onPrimary = Color(0xFF00110A), - background = bg, - onBackground = onBg, - surface = Color(0xFF111823), - onSurface = onBg, - surfaceVariant = Color(0xFF172232), - onSurfaceVariant = Color(0xFFB7C4D6), - outline = Color(0xFF334155), - ) - val baseTypography = Typography() - val techTypography = buildTechTypography(baseTypography, FontFamily.SansSerif) - - MaterialTheme(colorScheme = darkScheme, typography = techTypography) { - val scope = rememberCoroutineScope() - var isRunning by remember { mutableStateOf(false) } - var isPaused by remember { mutableStateOf(false) } - var handle by remember { mutableStateOf(null) } - val logs = remember { mutableStateListOf() } - var remainingMs by remember { mutableStateOf(0L) } - var showProperties by remember { mutableStateOf(false) } - - // Editable property state (as text for easy input/validation) - var initialDelayText by remember { mutableStateOf("500") } - var maxDelayText by remember { mutableStateOf("5000") } - var multiplierText by remember { mutableStateOf("1.8") } - var jitterText by remember { mutableStateOf("0.15") } - var maxAttemptsText by remember { mutableStateOf("12") } - var overallTimeoutText by remember { mutableStateOf("30000") } - var perAttemptTimeoutText by remember { mutableStateOf("") } // empty = null + val viewModel = remember { PollingViewModel() } + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() - // Retry strategy index - var retryStrategyIndex by remember { mutableStateOf(0) } - val retryStrategies = listOf( - "Always" to RetryPredicates.always, - "Never" to RetryPredicates.never, - "Network/Server/Timeout" to RetryPredicates.networkOrServerOrTimeout - ) - var pollingJob by remember { mutableStateOf(null) } + val retryStrategies = listOf( + "Always", + "Never", + "Any timeout" + ) + PollingEngineTheme { Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) ) { - Spacer(Modifier.height(40.dp)) - // Heading - Text( - text = "Polling Terminal", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(12.dp)) + item { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top + ) { + Spacer(Modifier.height(40.dp)) + // Heading + Text( + text = "Polling Terminal", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(12.dp)) - // Start button + countdown - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - GlowingButton( - enabled = !isRunning, - text = "Start Polling", - onClick = { - logs.clear() - // Parse and validate inputs - val initialDelay = initialDelayText.toLongOrNull() - val maxDelay = maxDelayText.toLongOrNull() - val multiplier = multiplierText.toDoubleOrNull() - val jitter = jitterText.toDoubleOrNull() - val maxAttempts = maxAttemptsText.toIntOrNull() - val overallTimeout = overallTimeoutText.toLongOrNull() - val perAttemptTimeout = - perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() - if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { - logs.add("[error] Invalid properties. Please enter valid numbers.") - return@GlowingButton - } - val backoff = BackoffPolicy( - initialDelayMs = initialDelay, - maxDelayMs = maxDelay, - multiplier = multiplier, - jitterRatio = jitter, - maxAttempts = maxAttempts, - overallTimeoutMs = overallTimeout, - perAttemptTimeoutMs = perAttemptTimeout, - ) - isRunning = true - isPaused = false - remainingMs = backoff.overallTimeoutMs - var attemptCounter = 0 - val config = pollingConfig { - fetch { - attemptCounter++ - if (attemptCounter < 8) PollingResult.Waiting else PollingResult.Success( - "Ready at attempt #$attemptCounter" - ) - } - success { it.isNotEmpty() } - backoff(backoff) - retry(retryStrategies[retryStrategyIndex].second) - onAttempt { attempt, delayMs -> - val baseDelay = - (backoff.initialDelayMs * backoff.multiplier.pow((attempt - 1).toDouble())).toLong() - .coerceAtMost(backoff.maxDelayMs) - val baseSecs = ((baseDelay) / 100L).toFloat() / 10f - val baseSecsStr = ((round(baseSecs * 10f)) / 10f).toString() - val actualDelay = delayMs ?: 0L - val actualSecs = (actualDelay / 100L).toFloat() / 10f - val actualSecsStr = ((round(actualSecs * 10f)) / 10f).toString() - logs.add("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") - } - onResult { attempt, result -> - logs.add("[info] Result at #$attempt: ${describeResult(result)}") - } - onComplete { attempts, durationMs, outcome -> - val secs = (durationMs / 100L).toFloat() / 10f - val secsStr = ((round(secs * 10f)) / 10f).toString() - logs.add( - "[done] Completed after $attempts attempts in ${secsStr}s: ${ - describeOutcome( - outcome - ) - }" + ControlPanel( + isRunning = uiState.isRunning, + isPaused = uiState.isPaused, + remainingMs = uiState.remainingMs, + onStart = { viewModel.dispatch(PollingIntent.StartPolling) }, + onPause = { viewModel.dispatch(PollingIntent.PauseOrResumePolling) }, + onStop = { viewModel.dispatch(PollingIntent.StopPolling) } + ) + + Spacer(Modifier.height(24.dp)) + + // Editable Properties panel (not in logs) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.dispatch(PollingIntent.ToggleProperties) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(Modifier.weight(1f)) { + Text( + (if (uiState.showProperties) "▼ " else "▶ ") + "Basic Setup", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + if (uiState.showProperties) { + Text( + "Configure delays, timeouts and retry strategy.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - pollingJob = scope.launch { - val outcome = Polling.run(config) - logs.add("[done] Final Outcome: ${describeOutcome(outcome)}") - isRunning = false - isPaused = false - remainingMs = 0 - handle = null - } - // Start countdown ticker respecting pause - scope.launch { - while (isRunning && remainingMs > 0) { - delay(100) - if (!isPaused) remainingMs = - (remainingMs - 100).coerceAtLeast(0) - } - } } - ) - Spacer(Modifier.weight(1f)) - val secs = (remainingMs / 100L).toFloat() / 10f - val secsStr = ((round(secs * 10f)) / 10f).toString() - Text( - text = if (isRunning) "${secsStr}s left" + if (isPaused) " (paused)" else "" else "", - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Medium - ) - - // Stop button - GlowingButton( - enabled = isRunning, - text = if (isPaused) "Resume" else "Pause", - onClick = { - isPaused = !isPaused - } - ) - Spacer(Modifier.width(8.dp)) - GlowingButton( - enabled = isRunning, - text = "Stop", - onClick = { - pollingJob?.cancel() - isRunning = false - isPaused = false - handle = null - remainingMs = 0 - } - ) - } + if (uiState.showProperties) { + HorizontalDivider( + Modifier.padding(vertical = 12.dp), + DividerDefaults.Thickness, + MaterialTheme.colorScheme.outline + ) + Surface( + tonalElevation = 3.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.92f + ), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SectionHeader("Delays & Backoff") + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LabeledField( + "Initial", + uiState.initialDelayText, + { viewModel.dispatch(PollingIntent.UpdateInitialDelay(it)) }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 500", + suffix = "ms", + keyboardType = KeyboardType.Number + ) + LabeledField( + "Max", + uiState.maxDelayText, + { viewModel.dispatch(PollingIntent.UpdateMaxDelay(it)) }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 5000", + suffix = "ms", + keyboardType = KeyboardType.Number + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LabeledField( + "Multiplier", + uiState.multiplierText, + { viewModel.dispatch(PollingIntent.UpdateMultiplier(it)) }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 1.8", + keyboardType = KeyboardType.Decimal + ) + LabeledField( + "Jitter", + uiState.jitterText, + { viewModel.dispatch(PollingIntent.UpdateJitter(it)) }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 0.15", + keyboardType = KeyboardType.Decimal + ) + } - Spacer(Modifier.height(16.dp)) + SectionDivider() + SectionHeader("Timeouts & Attempts") + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LabeledField( + "Max Attempts", + uiState.maxAttemptsText, + { viewModel.dispatch(PollingIntent.UpdateMaxAttempts(it)) }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 12", + keyboardType = KeyboardType.Number + ) + LabeledField( + "Overall", + uiState.overallTimeoutText, + { + viewModel.dispatch( + PollingIntent.UpdateOverallTimeout( + it + ) + ) + }, + modifier = Modifier.weight(1f), + placeholder = "e.g. 30000", + suffix = "ms", + keyboardType = KeyboardType.Number + ) + } + LabeledField( + "Per-attempt", + uiState.perAttemptTimeoutText, + { + viewModel.dispatch( + PollingIntent.UpdatePerAttemptTimeout( + it + ) + ) + }, + placeholder = "empty = unlimited", + suffix = "ms", + keyboardType = KeyboardType.Number + ) - // Editable Properties panel (not in logs) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showProperties = !showProperties }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - (if (showProperties) "▼ " else "▶ ") + "Properties", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground - ) - } - if (showProperties) { - HorizontalDivider( - Modifier.padding(vertical = 6.dp), - DividerDefaults.Thickness, MaterialTheme.colorScheme.outline - ) - PropertiesCard( - initialDelayText = initialDelayText, - onInitialChange = { initialDelayText = it }, - maxDelayText = maxDelayText, - onMaxDelayChange = { maxDelayText = it }, - multiplierText = multiplierText, - onMultiplierChange = { multiplierText = it }, - jitterText = jitterText, - onJitterChange = { jitterText = it }, - maxAttemptsText = maxAttemptsText, - onMaxAttemptsChange = { maxAttemptsText = it }, - overallTimeoutText = overallTimeoutText, - onOverallTimeoutChange = { overallTimeoutText = it }, - perAttemptTimeoutText = perAttemptTimeoutText, - onPerAttemptTimeoutChange = { perAttemptTimeoutText = it } - ) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - GlowingButton( - enabled = isRunning && handle != null, - text = "Apply Backoff", - onClick = { - fun appendLog(msg: String) { - scope.launch { logs.add(msg) } + // Live update button + GlowingButton( + enabled = uiState.isRunning, + text = "Apply Backoff at Runtime", + onClick = { viewModel.dispatch(PollingIntent.ApplyBackoffAtRuntime) } + ) } + } - val initialDelay = initialDelayText.toLongOrNull() - val maxDelay = maxDelayText.toLongOrNull() - val multiplier = multiplierText.toDoubleOrNull() - val jitter = jitterText.toDoubleOrNull() - val maxAttempts = maxAttemptsText.toIntOrNull() - val overallTimeout = overallTimeoutText.toLongOrNull() - val perAttemptTimeout = - perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() - if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { - appendLog("[error] Invalid properties; cannot apply backoff.") - return@GlowingButton - } - val newPolicy = try { - BackoffPolicy( - initialDelayMs = initialDelay, - maxDelayMs = maxDelay, - multiplier = multiplier, - jitterRatio = jitter, - maxAttempts = maxAttempts, - overallTimeoutMs = overallTimeout, - perAttemptTimeoutMs = perAttemptTimeout, + Spacer(Modifier.height(16.dp)) + + // Retry Strategy inside Basic setup + Surface( + tonalElevation = 2.dp, + shadowElevation = 1.dp, + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.7f + ), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.6f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Retry Strategy", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, ) - } catch (t: Throwable) { - appendLog("[error] ${t.message}") - return@GlowingButton - } - handle?.let { h -> - scope.launch { - Polling.updateBackoff( - h.id, - newPolicy - ) + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + retryStrategies.forEachIndexed { idx, label -> + val selected = uiState.retryStrategyIndex == idx + Button( + onClick = { + viewModel.dispatch( + PollingIntent.UpdateRetryStrategy( + idx + ) + ) + }, + enabled = true, + shape = RoundedCornerShape(8.dp), + border = BorderStroke( + 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text( + label, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + } + } } } - appendLog("[info] Applied new backoff policy at runtime.") } - ) + } } } - - Spacer(Modifier.height(16.dp)) - - // Retry strategy selector - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Retry Strategy:", color = MaterialTheme.colorScheme.onBackground) - Spacer(Modifier.width(8.dp)) - retryStrategies.forEachIndexed { idx, (label, _) -> - GlowingButton( - enabled = retryStrategyIndex != idx, - text = label, - onClick = { retryStrategyIndex = idx }, - ) - Spacer(Modifier.width(4.dp)) - } + item { + Spacer(Modifier.height(24.dp)) + } + items(items = uiState.logs, key = { it.id }) { line -> + LogEntryCard(line = line.text) } - Spacer(Modifier.height(8.dp)) - - // Terminal Log view - TerminalLog(modifier = Modifier.weight(1f), logs = logs) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt new file mode 100644 index 0000000..aeb6121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt @@ -0,0 +1,298 @@ +package `in`.androidplay.pollingengine + +import `in`.androidplay.pollingengine.models.PollingResult +import `in`.androidplay.pollingengine.polling.BackoffPolicy +import `in`.androidplay.pollingengine.polling.Polling +import `in`.androidplay.pollingengine.polling.PollingOutcome +import `in`.androidplay.pollingengine.polling.PollingSession +import `in`.androidplay.pollingengine.polling.RetryPredicates +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.math.pow +import kotlin.math.round + +data class LogItem(val text: String, val id: Long) + +private val logIdCounter = atomic(0L) + +data class PollingUiState( + val isRunning: Boolean = false, + val isPaused: Boolean = false, + val remainingMs: Long = 0L, + val logs: List = emptyList(), + val showProperties: Boolean = false, + + // Editable properties + val initialDelayText: String = "500", + val maxDelayText: String = "5000", + val multiplierText: String = "1.8", + val jitterText: String = "0.15", + val maxAttemptsText: String = "12", + val overallTimeoutText: String = "30000", + val perAttemptTimeoutText: String = "", + + val retryStrategyIndex: Int = 0, +) + +sealed interface PollingIntent { + data object StartPolling : PollingIntent + data object PauseOrResumePolling : PollingIntent + data object StopPolling : PollingIntent + data object ToggleProperties : PollingIntent + data class UpdateInitialDelay(val value: String) : PollingIntent + data class UpdateMaxDelay(val value: String) : PollingIntent + data class UpdateMultiplier(val value: String) : PollingIntent + data class UpdateJitter(val value: String) : PollingIntent + data class UpdateMaxAttempts(val value: String) : PollingIntent + data class UpdateOverallTimeout(val value: String) : PollingIntent + data class UpdatePerAttemptTimeout(val value: String) : PollingIntent + data class UpdateRetryStrategy(val index: Int) : PollingIntent + data object ApplyBackoffAtRuntime : PollingIntent +} + +class PollingViewModel { + + private val _uiState = MutableStateFlow(PollingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val viewModelScope = CoroutineScope(Dispatchers.Default) + private var pollingJob: Job? = null + private var countdownJob: Job? = null + private var pollingSession: PollingSession? = null + + private val retryStrategies = listOf( + "Always" to RetryPredicates.always, + "Never" to RetryPredicates.never, + "Any timeout" to RetryPredicates.networkOrServerOrTimeout + ) + + fun dispatch(intent: PollingIntent) { + when (intent) { + is PollingIntent.StartPolling -> startPolling() + is PollingIntent.PauseOrResumePolling -> pauseOrResumePolling() + is PollingIntent.StopPolling -> stopPolling() + is PollingIntent.ToggleProperties -> _uiState.update { it.copy(showProperties = !it.showProperties) } + is PollingIntent.UpdateInitialDelay -> _uiState.update { it.copy(initialDelayText = intent.value) } + is PollingIntent.UpdateMaxDelay -> _uiState.update { it.copy(maxDelayText = intent.value) } + is PollingIntent.UpdateMultiplier -> _uiState.update { it.copy(multiplierText = intent.value) } + is PollingIntent.UpdateJitter -> _uiState.update { it.copy(jitterText = intent.value) } + is PollingIntent.UpdateMaxAttempts -> _uiState.update { it.copy(maxAttemptsText = intent.value) } + is PollingIntent.UpdateOverallTimeout -> _uiState.update { it.copy(overallTimeoutText = intent.value) } + is PollingIntent.UpdatePerAttemptTimeout -> _uiState.update { + it.copy( + perAttemptTimeoutText = intent.value + ) + } + + is PollingIntent.UpdateRetryStrategy -> _uiState.update { it.copy(retryStrategyIndex = intent.index) } + is PollingIntent.ApplyBackoffAtRuntime -> applyBackoffAtRuntime() + } + } + + private fun addLog(text: String) { + _uiState.update { + it.copy(logs = it.logs + LogItem(text, logIdCounter.incrementAndGet())) + } + } + + private fun startPolling() { + _uiState.update { it.copy(showProperties = false, logs = emptyList()) } + + val currentState = _uiState.value + val initialDelay = currentState.initialDelayText.toLongOrNull() + val maxDelay = currentState.maxDelayText.toLongOrNull() + val multiplier = currentState.multiplierText.toDoubleOrNull() + val jitter = currentState.jitterText.toDoubleOrNull() + val maxAttempts = currentState.maxAttemptsText.toIntOrNull() + val overallTimeout = currentState.overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + currentState.perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (currentState.perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + addLog("[error] Invalid properties. Please enter valid numbers.") + return + } + + val backoff = BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + + _uiState.update { + it.copy( + isRunning = true, + isPaused = false, + remainingMs = backoff.overallTimeoutMs + ) + } + + var attemptCounter = 0 + pollingJob = Polling.startPolling { + this.fetch = { + attemptCounter++ + if (attemptCounter < 8) PollingResult.Waiting else PollingResult.Success("Ready at attempt #$attemptCounter") + } + this.isTerminalSuccess = { it.isNotEmpty() } + this.backoff = backoff + this.shouldRetryOnError = retryStrategies[_uiState.value.retryStrategyIndex].second + this.onAttempt = { attempt, delayMs -> + val baseDelay = + (backoff.initialDelayMs * backoff.multiplier.pow((attempt - 1).toDouble())).toLong() + .coerceAtMost(backoff.maxDelayMs) + val baseSecs = ((baseDelay) / 100L).toFloat() / 10f + val baseSecsStr = ((round(baseSecs * 10f)) / 10f).toString() + val actualDelay = delayMs ?: 0L + val actualSecs = (actualDelay / 100L).toFloat() / 10f + val actualSecsStr = ((round(actualSecs * 10f)) / 10f).toString() + addLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") + } + this.onResult = { attempt, result -> + addLog("[info] Result at #$attempt: ${describeResult(result)}") + } + this.onComplete = { attempts, durationMs, outcome -> + val secs = (durationMs / 100L).toFloat() / 10f + val secsStr = ((round(secs * 10f)) / 10f).toString() + addLog( + "[done] Completed after $attempts attempts in ${secsStr}s: ${ + describeOutcome( + outcome + ) + }" + ) + } + }.onEach { outcome -> + addLog("[done] Final Outcome: ${describeOutcome(outcome)}") + _uiState.update { + it.copy( + isRunning = false, + isPaused = false, + remainingMs = 0 + ) + } + }.launchIn(viewModelScope) + + startCountdown() + } + + private fun pauseOrResumePolling() { + val isCurrentlyPaused = _uiState.value.isPaused + _uiState.update { it.copy(isPaused = !isCurrentlyPaused) } + pollingSession?.let { session -> + viewModelScope.launch { + if (!isCurrentlyPaused) Polling.pause(session.id) else Polling.resume(session.id) + } + } + } + + private fun stopPolling() { + pollingSession?.let { session -> + viewModelScope.launch { Polling.cancel(session.id) } + } + pollingJob?.cancel() + countdownJob?.cancel() + _uiState.update { + it.copy( + isRunning = false, + isPaused = false, + remainingMs = 0 + ) + } + pollingSession = null + } + + private fun applyBackoffAtRuntime() { + val currentState = _uiState.value + val initialDelay = currentState.initialDelayText.toLongOrNull() + val maxDelay = currentState.maxDelayText.toLongOrNull() + val multiplier = currentState.multiplierText.toDoubleOrNull() + val jitter = currentState.jitterText.toDoubleOrNull() + val maxAttempts = currentState.maxAttemptsText.toIntOrNull() + val overallTimeout = currentState.overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + currentState.perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (currentState.perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + addLog("[error] Invalid properties; cannot apply backoff.") + return + } + + val newPolicy = try { + BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + } catch (t: Throwable) { + addLog("[error] ${t.message}") + return + } + + pollingSession?.let { session -> + viewModelScope.launch { + Polling.updateBackoff(session.id, newPolicy) + addLog("[info] Applied new backoff policy at runtime.") + } + } ?: run { + addLog("[error] Live update not available; stop and start with new settings.") + } + } + + private fun startCountdown() { + countdownJob = viewModelScope.launch { + while (_uiState.value.isRunning && _uiState.value.remainingMs > 0) { + kotlinx.coroutines.delay(100) + if (!_uiState.value.isPaused) { + _uiState.update { it.copy(remainingMs = (it.remainingMs - 100).coerceAtLeast(0)) } + } + } + } + } + + private fun describeResult(result: PollingResult): String = when (result) { + is PollingResult.Success -> "Success(${result.data})" + is PollingResult.Failure -> "Failure(code=${result.error.code}, msg=${result.error.message})" + is PollingResult.Waiting -> "Waiting" + is PollingResult.Cancelled -> "Cancelled" + is PollingResult.Unknown -> "Unknown" + } + + private fun describeOutcome(outcome: PollingOutcome): String = when (outcome) { + is PollingOutcome.Success -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" + } + + is PollingOutcome.Exhausted -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" + } + + is PollingOutcome.Timeout -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Timeout(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" + } + + is PollingOutcome.Cancelled -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((round(secs * 10f)) / 10f)})" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Theme.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Theme.kt new file mode 100644 index 0000000..b32556b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Theme.kt @@ -0,0 +1,61 @@ +package `in`.androidplay.pollingengine + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily + +private val neonPrimary = Color(0xFF00E5A8) +private val bg = Color(0xFF0B1015) +private val onBg = Color(0xFFE6F1FF) + +private val darkScheme = darkColorScheme( + primary = neonPrimary, + onPrimary = Color(0xFF00110A), + background = bg, + onBackground = onBg, + surface = Color(0xFF111823), + onSurface = onBg, + surfaceVariant = Color(0xFF172232), + onSurfaceVariant = Color(0xFFB7C4D6), + outline = Color(0xFF334155), +) + +private fun buildTechTypography( + base: Typography, + family: FontFamily +): Typography { + return Typography( + displayLarge = base.displayLarge.copy(fontFamily = family), + displayMedium = base.displayMedium.copy(fontFamily = family), + displaySmall = base.displaySmall.copy(fontFamily = family), + headlineLarge = base.headlineLarge.copy(fontFamily = family), + headlineMedium = base.headlineMedium.copy(fontFamily = family), + headlineSmall = base.headlineSmall.copy(fontFamily = family), + titleLarge = base.titleLarge.copy(fontFamily = family), + titleMedium = base.titleMedium.copy(fontFamily = family), + titleSmall = base.titleSmall.copy(fontFamily = family), + bodyLarge = base.bodyLarge.copy(fontFamily = family), + bodyMedium = base.bodyMedium.copy(fontFamily = family), + bodySmall = base.bodySmall.copy(fontFamily = family), + labelLarge = base.labelLarge.copy(fontFamily = family), + labelMedium = base.labelMedium.copy(fontFamily = family), + labelSmall = base.labelSmall.copy(fontFamily = family), + ) +} + +private val baseTypography = Typography() +private val techTypography = buildTechTypography(baseTypography, FontFamily.SansSerif) + +@Composable +internal fun PollingEngineTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = darkScheme, + typography = techTypography, + content = content + ) +} diff --git a/docs/PollingEngine.md b/docs/PollingEngine.md deleted file mode 100644 index e0500dc..0000000 --- a/docs/PollingEngine.md +++ /dev/null @@ -1,160 +0,0 @@ -# PollingEngine — Developer Guide - -Last updated: 2025-09-07 02:33 - -This guide explains the public API exposed to app developers and how to use it from Android/iOS. The -library provides a robust polling engine with exponential backoff, retry predicates, and runtime -controls. - -Key concepts: - -- Polling facade (Polling): the single entry point apps use. -- PollingApi: the public interface implemented by Polling. -- PollingConfig DSL: declare what to fetch, how to detect success, and how to back off. -- BackoffPolicy: controls delays, jitter, attempts, and timeouts. -- Outcomes: PollingOutcome is the terminal result (Success, Exhausted, Timeout, Cancelled). - -## Quick start - -Create a config and run once (suspending): - -```kotlin -import in .androidplay.pollingengine.models.PollingResult -import in .androidplay.pollingengine.polling.* - -val config = pollingConfig { - fetch { - // Return a PollingResult based on your current state/network - // e.g. call server and map response to PollingResult - PollingResult.Waiting // or Success(data), Failure(error), Unknown, Cancelled - } - success { data -> data == "READY" } - // Choose a retry predicate (see RetryPredicates below) - retry(RetryPredicates.networkOrServerOrTimeout) - backoff(BackoffPolicies.quick20s) -} - -suspend fun runOnce(): PollingOutcome = Polling.run(config) -``` - -Start background polling with a callback and control it later: - -```kotlin -val handle = Polling.startPolling(config) { outcome -> - // Called when polling reaches a terminal outcome - println("Outcome: $outcome") -} - -// Pause/Resume -kotlinx.coroutines.GlobalScope.launch { Polling.pause(handle.id) } -kotlinx.coroutines.GlobalScope.launch { Polling.resume(handle.id) } - -// Update backoff at runtime -kotlinx.coroutines.GlobalScope.launch { - Polling.updateBackoff(handle.id, BackoffPolicies.quick20s) -} - -// Cancel -kotlinx.coroutines.GlobalScope.launch { Polling.cancel(handle) } -``` - -## API surface (stable) - -Polling facade implements the following interface: - -```kotlin -interface PollingApi { - fun activePollsCount(): Int - suspend fun listActiveIds(): List - - suspend fun cancel(id: String) - suspend fun cancel(session: in.androidplay.pollingengine.polling.PollingSession) - suspend fun cancelAll() - suspend fun shutdown() - - suspend fun pause(id: String) - suspend fun resume(id: String) - suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) - - fun startPolling( - config: PollingConfig, - onComplete: (PollingOutcome) -> Unit, - ): in.androidplay.pollingengine.polling.PollingSession - - suspend fun run(config: PollingConfig): PollingOutcome - suspend fun compose(vararg configs: PollingConfig): PollingOutcome -} -``` - -Use Polling everywhere in apps; Polling delegates to the internal PollingEngine implementation. - -## DSL overview (PollingConfig) - -```kotlin -val config = pollingConfig { - fetch { - // Do work and return a PollingResult - // Success(value), Waiting, Failure(error), Unknown, Cancelled - PollingResult.Waiting - } - success { value -> value.isComplete } - - // Retry predicate receives a domain Error? for error cases (see RetryPredicates) - retry { err -> - // Example: retry for network/server/timeout/unknown - RetryPredicates.networkOrServerOrTimeout(err) - } - - // Observability hooks - onAttempt { attempt, delayMs -> println("Attempt #$attempt, next delay=$delayMs ms") } - onResult { attempt, result -> println("Result at $attempt: $result") } - onComplete { attempts, durationMs, outcome -> println("Done in $attempts attempts: $outcome") } - - backoff( - BackoffPolicy( - initialDelayMs = 500, - maxDelayMs = 5000, - multiplier = 1.8, - jitterRatio = 0.15, - maxAttempts = 12, - overallTimeoutMs = 30_000, - perAttemptTimeoutMs = null, - ) - ) -} -``` - -### RetryPredicates - -Built-ins to reduce boilerplate: - -```kotlin -retry(RetryPredicates.networkOrServerOrTimeout) -// or -retry(RetryPredicates.always) -// or -retry(RetryPredicates.never) -``` - -## Sample app integration - -The Compose Multiplatform sample uses the Polling facade: - -- Start: Polling.startPolling(config) { outcome -> ... } -- Pause/Resume: Polling.pause(id), Polling.resume(id) -- Cancel: Polling.cancel(handle) -- Update backoff: Polling.updateBackoff(id, policy) - -See composeApp/src/commonMain/.../App.kt for a full example with UI and logs. - -## Migration notes (rename) - -- PollingEngineApi has been renamed to PollingApi. -- A dedicated facade object Polling now implements PollingApi and should be used by apps. -- Internal details remain in PollingEngine; apps should avoid direct usage and prefer Polling. - -## Reference docs - -- Generate Dokka: `./gradlew :pollingengine:dokkaHtml` -- Output: pollingengine/build/dokka/html/index.html - diff --git a/docs/ci-setup.md b/docs/ci-setup.md deleted file mode 100644 index 71805fb..0000000 --- a/docs/ci-setup.md +++ /dev/null @@ -1,183 +0,0 @@ -# [ARCHIVED] CI and Release Setup Guide - -Note: CI workflows and publishing configuration have been removed from this repository as part of a -cleanup. This document is retained for reference only. Do not commit credentials or workflow files. - -Last updated: 2025-09-07 02:33 - -This document explains how to set up Continuous Integration (CI) for this repository and how to publish the PollingEngine libraries to Maven Central (Android/KMP) and CocoaPods (iOS). It assumes you are using GitHub as the hosting platform. - -Context: The Gradle build for `:pollingengine` already includes publishing and signing configuration, Dokka docs, Detekt, ktlint, and CocoaPods support. CI will orchestrate tasks and provide credentials via secrets. - ---- - -## 1) Prerequisites - -- GitHub repository admin access -- Java 11 (used by the build) -- A Mac runner is required for iOS/Kotlin/Native tasks (GitHub-hosted `macos-latest` works) -- Maven Central Portal account with access to your groupId and Central Portal credentials (e.g., - `in.androidplay`) -- GPG key for signing artifacts (public key published to a keyserver) -- CocoaPods installed locally for validation (optional in CI, but required for `pod trunk push`) - ---- - -## 2) Local setup (for maintainers) - -These steps help you validate before pushing tags that trigger release. - -1. Configure your local `~/.gradle/gradle.properties` with the following (do not commit this file): - - signing.keyId=YOUR_KEY_ID - - signing.password=YOUR_GPG_PASSPHRASE - - signing.key=-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK----- - # Optional legacy path (not recommended): if you have a key ring file, you can set - # signing.secretKeyRingFile=/path/to/your_secret.gpg - # The build will load it into memory; no - `gpg` binary is required. Do NOT set signing.useGpg unless you want to use system gpg. - - mavenCentralUsername=YOUR_CENTRAL_PORTAL_USERNAME - - mavenCentralPassword=YOUR_CENTRAL_PORTAL_PASSWORD - -2. Dry-run a local publish (legacy direct): - - ./gradlew :pollingengine:publish - -3. Generate docs locally: - - ./gradlew :pollingengine:dokkaHtml - -4. CocoaPods podspec generation and lint (optional): - - ./gradlew :pollingengine:podspec - - pod lib lint pollingengine/pollingengine.podspec --allow-warnings - ---- - -## 3) Required GitHub Secrets - -Set these secrets in your GitHub repository under Settings → Security → Secrets and variables → Actions → New repository secret. - -Core publishing: - -- MAVEN_CENTRAL_USERNAME: Central Portal username -- MAVEN_CENTRAL_PASSWORD: Central Portal password -- SIGNING_KEY_ID: Your GPG key ID (short or long ID as used by Gradle signing) -- SIGNING_PASSWORD: Passphrase for the GPG private key -- SIGNING_KEY: ASCII-armored GPG private key contents (single line or multiline; ensure proper YAML quoting in workflow if needed) - -Optional (documentation publishing, if enabled in workflows): -- GH_PAGES_TOKEN: A token with permissions to push to gh-pages (PAT if GITHUB_TOKEN permissions aren’t sufficient) - -Optional (CocoaPods trunk): -- COCOAPODS_TRUNK_TOKEN: CocoaPods trunk token (after `pod trunk register`) - -Notes: -- GitHub provides GITHUB_TOKEN automatically; it usually has repo-scoped permissions sufficient for creating releases and uploading assets if configured in the workflow permissions. - ---- - -## 4) Environment variables used by Gradle - -The Gradle signing and publishing configuration will read credentials from Gradle properties or environment variables. In CI, we typically export the GitHub Secrets as env vars so Gradle can discover them: - -- ORG_GRADLE_PROJECT_signing.keyId → SIGNING_KEY_ID -- ORG_GRADLE_PROJECT_signing.password → SIGNING_PASSWORD -- ORG_GRADLE_PROJECT_signing.key → SIGNING_KEY -- ORG_GRADLE_PROJECT_mavenCentralUsername → MAVEN_CENTRAL_USERNAME -- ORG_GRADLE_PROJECT_mavenCentralPassword → MAVEN_CENTRAL_PASSWORD - -Workflows should map secrets to these env vars, e.g.: -- env: - - ORG_GRADLE_PROJECT_signing.keyId: ${{ secrets.SIGNING_KEY_ID }} - - ORG_GRADLE_PROJECT_signing.password: ${{ secrets.SIGNING_PASSWORD }} - - ORG_GRADLE_PROJECT_signing.key: ${{ secrets.SIGNING_KEY }} - - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - -This allows Gradle to pick them up without committing anything sensitive. - ---- - -## 5) Suggested GitHub Actions workflows - -Although not committed here, you can create the following workflows under `.github/workflows/`. - -A) PR and main build (quality gates): -- Runs on push/pull_request to main branches -- Ubuntu job: `./gradlew build detekt ktlintCheck apiCheck dokkaHtml` -- macOS job: `./gradlew :pollingengine:assembleReleaseXCFramework` (optional) and tests -- Cache Gradle and Kotlin/Native - -B) Release workflow (tag-driven): -- Trigger: push tag `v*` (e.g., `v0.1.0`) -- macOS runner (needed for K/N): - - Set env vars from secrets (see section 4) - - Build artifacts: - `./gradlew clean build :pollingengine:assembleReleaseXCFramework :pollingengine:publish :pollingengine:dokkaHtml` - - Upload Dokka site and build artifacts as GitHub Release assets (optional) - - Optionally publish gh-pages docs if you maintain a docs site - -C) Optional CocoaPods publish job: -- Runs on tag `v*` or manual dispatch -- Steps: - - ./gradlew :pollingengine:podspec - - pod trunk push pollingengine/pollingengine.podspec --allow-warnings -- Requires `COCOAPODS_TRUNK_TOKEN` secret and a macOS runner with CocoaPods installed (use `brew install cocoapods` step if needed). - ---- - -## 6) Releasing - -1) Tag-based release (Maven Central): -- Ensure `group` and `version` in `pollingengine/build.gradle.kts` are correct -- Update CHANGELOG.md -- Create and push a tag `vX.Y.Z`: - - git tag v0.1.0 - - git push origin v0.1.0 -- CI release workflow publishes to Maven Central (Central Portal) and closes/releases the staging - repository -- Wait for Maven Central sync (can take up to ~2 hours) - -2) CocoaPods release: -- Ensure the generated `pollingengine.podspec` has the right version and metadata -- Validate locally: `pod lib lint pollingengine/pollingengine.podspec --allow-warnings` -- Publish: `pod trunk push pollingengine/pollingengine.podspec --allow-warnings` -- Consumers can then `pod 'PollingEngine', '~> X.Y'` - -3) Docs and assets (optional): -- Upload the XCFramework zip and docs to the GitHub Release page or publish docs to GitHub Pages - ---- - -## 7) Local validation checklist before cutting a release - -- ./gradlew clean build -- ./gradlew :pollingengine:dokkaHtml -- ./gradlew :pollingengine:assembleReleaseXCFramework -- ./gradlew :pollingengine:podspec -- pod lib lint pollingengine/pollingengine.podspec --allow-warnings (optional) -- ./gradlew :pollingengine:publishAllPublicationsToMavenCentralRepository :pollingengine: - closeAndReleaseMavenCentralStagingRepository (optional dry-run with real creds) - ---- - -## 8) Troubleshooting - -- Signing errors (e.g., "No appropriate signing key"): Verify SIGNING_KEY, SIGNING_KEY_ID, and SIGNING_PASSWORD are set and correctly mapped to ORG_GRADLE_PROJECT_* env vars. -- Central Portal errors (401/403): Verify MAVEN_CENTRAL_USERNAME/MAVEN_CENTRAL_PASSWORD and groupId - ownership. -- iOS build failures on Linux runners: Use macOS runners for Kotlin/Native iOS tasks. -- CocoaPods push rejection: Ensure the spec version matches a git tag, the homepage and source URLs are reachable, and that you’re registered on CocoaPods trunk. -- Dokka task issues: Ensure the Dokka plugin version matches the Kotlin version; re-run `./gradlew :pollingengine:dokkaHtml` with `--info` for details. - ---- - -## 9) Reference tasks in this repo - -Common Gradle tasks you’ll use: -- Build and test: `./gradlew build` -- Lint and static analysis: `./gradlew detekt ktlintCheck` -- API check (if configured): `./gradlew apiCheck` -- Dokka docs: `./gradlew :pollingengine:dokkaHtml` -- Publish to Maven Central (legacy direct): `./gradlew :pollingengine:publish` -- Generate Podspec: `./gradlew :pollingengine:podspec` -- Build XCFramework: `./gradlew :pollingengine:assembleReleaseXCFramework` - -If you need concrete workflow YAML examples later, you can copy the env var mappings in Section 4 directly into your `.github/workflows/release.yml`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ec7e3e..42e668f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,13 +9,15 @@ androidx-core = "1.17.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.9.3" androidx-testExt = "1.3.0" +atomicfu = "0.29.0" composeMultiplatform = "1.8.2" junit = "4.13.2" kotlin = "2.2.10" -pollingengine = "0.1.0" +pollingengine = "0.1.1" kotlinx-coroutines = "1.10.2" [libraries] +atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { module = "junit:junit", version.ref = "junit" } diff --git a/pollingengine/build.gradle.kts b/pollingengine/build.gradle.kts index 095a8fd..f9af9d9 100644 --- a/pollingengine/build.gradle.kts +++ b/pollingengine/build.gradle.kts @@ -38,7 +38,7 @@ kotlin { listOf( iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "PollingEngine" @@ -63,7 +63,9 @@ android { defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") + consumerProguardFiles( + "consumer-rules.pro", + ) } compileOptions { diff --git a/pollingengine/pollingengine.podspec b/pollingengine/pollingengine.podspec index 1b72186..0a8955c 100644 --- a/pollingengine/pollingengine.podspec +++ b/pollingengine/pollingengine.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'pollingengine' - spec.version = '0.1.0' + spec.version = '0.1.1' spec.homepage = 'https://github.com/androidplay/PollingEngine' spec.source = { :http=> ''} spec.authors = '' diff --git a/pollingengine/publishing.gradle.kts b/pollingengine/publishing.gradle.kts index 036074e..bf6d85e 100644 --- a/pollingengine/publishing.gradle.kts +++ b/pollingengine/publishing.gradle.kts @@ -4,11 +4,16 @@ import org.gradle.plugins.signing.Sign // Publishing and signing configuration extracted from build.gradle.kts to declutter the module script. fun prop(name: String): String? = - (project.findProperty(name) as String?)?.trim()?.takeIf { it.isNotEmpty() } + (project.findProperty(name) as String?) + ?.trim() + ?.takeIf { it.isNotEmpty() } val signingEnabledGate: Boolean = run { - val fromProp = prop("signing.enabled")?.toBooleanStrictOrNull() - val fromEnv = System.getenv("SIGNING_ENABLED")?.trim()?.takeIf { it.isNotEmpty() } + val fromProp = prop("signing.enabled") + ?.toBooleanStrictOrNull() + val fromEnv = System.getenv("SIGNING_ENABLED") + ?.trim() + ?.takeIf { it.isNotEmpty() } ?.toBooleanStrictOrNull() (fromProp ?: fromEnv) == true } @@ -18,7 +23,8 @@ val hasInMemorySigning: Boolean = listOf("signing.key", "signing.password") val hasSecretKeyRingSigning: Boolean = run { val keyRing = prop("signing.secretKeyRingFile") - val passwordOk = prop("signing.password")?.isNotBlank() == true + val passwordOk = prop("signing.password") + ?.isNotBlank() == true keyRing?.let { file(it).exists() } == true && passwordOk } @@ -41,40 +47,60 @@ extensions.getByName("mavenPublishing").withGroovyBuilder { if (shouldSignPublications) { "signAllPublications"() } else { - println("[mavenPublishing] No signing config detected. Skipping signing of publications.") + println( + "[mavenPublishing] No signing config detected. Skipping signing of publications." + ) } // Define POM metadata required by Maven Central. Values are read from Gradle properties. "pom" { fun p(key: String) = providers.gradleProperty(key) - // Top-level POM fields - (getProperty("name") as Property).set(p("pom.name")) - (getProperty("description") as Property).set(p("pom.description")) - (getProperty("url") as Property).set(p("pom.url")) - + (getProperty("name") as Property).set( + p("pom.name") + ) + (getProperty("description") as Property).set( + p("pom.description") + ) + (getProperty("url") as Property).set( + p("pom.url") + ) // Licenses "licenses" { "license" { - (getProperty("name") as Property).set(p("pom.license.name")) - (getProperty("url") as Property).set(p("pom.license.url")) + (getProperty("name") as Property).set( + p("pom.license.name") + ) + (getProperty("url") as Property).set( + p("pom.license.url") + ) } } - // Developers "developers" { "developer" { - (getProperty("id") as Property).set(p("pom.developer.id")) - (getProperty("name") as Property).set(p("pom.developer.name")) - (getProperty("url") as Property).set(p("pom.developer.url")) + (getProperty("id") as Property).set( + p("pom.developer.id") + ) + (getProperty("name") as Property).set( + p("pom.developer.name") + ) + (getProperty("url") as Property).set( + p("pom.developer.url") + ) } } - // SCM "scm" { - (getProperty("url") as Property).set(p("pom.scm.url")) - (getProperty("connection") as Property).set(p("pom.scm.connection")) - (getProperty("developerConnection") as Property).set(p("pom.scm.developerConnection")) + (getProperty("url") as Property).set( + p("pom.scm.url") + ) + (getProperty("connection") as Property).set( + p("pom.scm.connection") + ) + (getProperty("developerConnection") as Property).set( + p("pom.scm.developerConnection") + ) } } } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/models/PollingResult.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/models/PollingResult.kt index 1ec9936..d212f6d 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/models/PollingResult.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/models/PollingResult.kt @@ -4,9 +4,17 @@ package `in`.androidplay.pollingengine.models * Generic result wrapper used by the polling engine to represent remote/API state. */ public sealed class PollingResult { - public data class Success(val data: T) : PollingResult() - public data class Failure(val error: Error) : PollingResult() + public data class Success( + val data: T, + ) : PollingResult() + + public data class Failure( + val error: Error, + ) : PollingResult() + public data object Cancelled : PollingResult() + public data object Waiting : PollingResult() + public data object Unknown : PollingResult() } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt index a78e70e..9c676b3 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt @@ -4,7 +4,6 @@ package `in`.androidplay.pollingengine.polling * Factory Pattern: predefined BackoffPolicy configurations for common scenarios. */ public object BackoffPolicies { - /** * Quick polling tuned for short-lived availability (e.g., compliance status) with ~20s cap. */ diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt index 8e3f874..06c63a8 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Core.kt @@ -3,7 +3,6 @@ package `in`.androidplay.pollingengine.polling import `in`.androidplay.pollingengine.models.Error import `in`.androidplay.pollingengine.models.PollingResult - // --- Internal error codes to decouple from external sources --- internal object ErrorCodes { internal const val UNKNOWN_ERROR_CODE: Int = -1 @@ -25,15 +24,21 @@ internal interface RetryStrategy { fun shouldRetry(error: Error?): Boolean } -internal class LambdaFetchStrategy(private val block: suspend () -> PollingResult) : FetchStrategy { +internal class LambdaFetchStrategy( + private val block: suspend () -> PollingResult, +) : FetchStrategy { override suspend fun fetch(): PollingResult = block() } -internal class LambdaSuccessStrategy(private val predicate: (T) -> Boolean) : SuccessStrategy { +internal class LambdaSuccessStrategy( + private val predicate: (T) -> Boolean, +) : SuccessStrategy { override fun isTerminal(value: T): Boolean = predicate(value) } -internal class LambdaRetryStrategy(private val predicate: (Error?) -> Boolean) : RetryStrategy { +internal class LambdaRetryStrategy( + private val predicate: (Error?) -> Boolean, +) : RetryStrategy { override fun shouldRetry(error: Error?): Boolean = predicate(error) } @@ -47,7 +52,11 @@ public object RetryPredicates { */ public val networkOrServerOrTimeout: (Error?) -> Boolean = { err -> when (err?.code) { - ErrorCodes.NETWORK_ERROR, ErrorCodes.SERVER_ERROR_CODE, ErrorCodes.TIMEOUT_ERROR_CODE, ErrorCodes.UNKNOWN_ERROR_CODE -> true + ErrorCodes.NETWORK_ERROR, + ErrorCodes.SERVER_ERROR_CODE, + ErrorCodes.TIMEOUT_ERROR_CODE, + ErrorCodes.UNKNOWN_ERROR_CODE, + -> true else -> false } } @@ -61,8 +70,26 @@ public object RetryPredicates { // --- Polling outcome --- public sealed class PollingOutcome { - public data class Success(val value: T, val attempts: Int, val elapsedMs: Long) : PollingOutcome() - public data class Exhausted(val last: PollingResult<*>?, val attempts: Int, val elapsedMs: Long) : PollingOutcome() - public data class Timeout(val last: PollingResult<*>?, val attempts: Int, val elapsedMs: Long) : PollingOutcome() - public data class Cancelled(val attempts: Int, val elapsedMs: Long) : PollingOutcome() + public data class Success( + val value: T, + val attempts: Int, + val elapsedMs: Long, + ) : PollingOutcome() + + public data class Exhausted( + val last: PollingResult<*>?, + val attempts: Int, + val elapsedMs: Long, + ) : PollingOutcome() + + public data class Timeout( + val last: PollingResult<*>?, + val attempts: Int, + val elapsedMs: Long, + ) : PollingOutcome() + + public data class Cancelled( + val attempts: Int, + val elapsedMs: Long, + ) : PollingOutcome() } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt index bab47ef..85ca29c 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt @@ -1,5 +1,8 @@ package `in`.androidplay.pollingengine.polling +import `in`.androidplay.pollingengine.polling.builder.PollingConfigBuilder +import kotlinx.coroutines.flow.Flow + /** * Public facade instance for consumers. Delegates to the internal engine. * @@ -20,10 +23,15 @@ public object Polling : PollingApi { PollingEngine.updateBackoff(id, newPolicy) override fun startPolling( - config: PollingConfig, - onComplete: (PollingOutcome) -> Unit, - ): PollingSession = - PollingEngine.startPolling(config, onComplete).let { PollingSession(it.id) } + config: PollingConfig + ): Flow> = PollingEngine.startPolling(config) + + override fun startPolling( + builder: PollingConfigBuilder.() -> Unit + ): Flow> { + val config = PollingConfigBuilder().apply(builder).build() + return startPolling(config) + } override suspend fun run(config: PollingConfig): PollingOutcome = PollingEngine.pollUntil(config) @@ -31,4 +39,3 @@ public object Polling : PollingApi { override suspend fun compose(vararg configs: PollingConfig): PollingOutcome = PollingEngine.compose(*configs) } - diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt index ab4482e..d6b64e7 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt @@ -1,5 +1,8 @@ package `in`.androidplay.pollingengine.polling +import `in`.androidplay.pollingengine.polling.builder.PollingConfigBuilder +import kotlinx.coroutines.flow.Flow + /** * Public abstraction layer for consumers. Exposes a small, stable API surface. * Internals are delegated to the PollingEngine implementation. @@ -34,9 +37,13 @@ public interface PollingApi { /** Start a new polling session. Returns a lightweight [PollingSession] handle. */ public fun startPolling( - config: PollingConfig, - onComplete: (PollingOutcome) -> Unit, - ): PollingSession + config: PollingConfig + ): Flow> + + /** Start a new polling session. Returns a lightweight [PollingSession] handle. */ + public fun startPolling( + builder: PollingConfigBuilder.() -> Unit + ): Flow> /** One-shot polling that runs to completion synchronously (suspending). */ public suspend fun run(config: PollingConfig): PollingOutcome @@ -44,4 +51,3 @@ public interface PollingApi { /** Compose multiple polling configs sequentially. */ public suspend fun compose(vararg configs: PollingConfig): PollingOutcome } - diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt index 1c656da..2adf058 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt @@ -1,3 +1,4 @@ + package `in`.androidplay.pollingengine.polling import `in`.androidplay.pollingengine.models.PollingResult @@ -11,8 +12,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive @@ -95,16 +99,16 @@ internal object PollingEngine { } fun startPolling( - config: PollingConfig, - onComplete: (PollingOutcome) -> Unit, - ): Handle { + config: PollingConfig + ): Flow> = channelFlow { if (isShutdown) throw IllegalStateException("PollingEngine is shut down") val id = generateId() val control = Control(id) val job = scope.launch(config.dispatcher) { val outcome = pollUntil(config, control) try { - onComplete(outcome) + send(outcome) + close() } finally { mutex.withLock { active.remove(id) @@ -112,13 +116,13 @@ internal object PollingEngine { } } } - scope.launch { - mutex.withLock { - active[id] = job - controls[id] = control - } + mutex.withLock { + active[id] = job + controls[id] = control + } + awaitClose { + job.cancel() } - return Handle(id) } /** @@ -158,10 +162,6 @@ internal object PollingEngine { try { while (attempt < (control.backoff.value ?: config.backoff).maxAttempts) { - // Suspend while paused - if (control.state.value == State.Paused) { - control.state.map { it == State.Running }.first { it } - } ensureActive() attempt++ @@ -257,6 +257,11 @@ internal object PollingEngine { config.onAttempt(nextAttemptIndex, sleepMs) } + // Suspend while paused + if (control.state.value == State.Paused) { + control.state.map { it == State.Running }.first { it } + } + delay(minOf(sleepMs, remainingBeforeSleep)) } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/builder/PollingConfigBuilder.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/builder/PollingConfigBuilder.kt new file mode 100644 index 0000000..e9a1a3e --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/builder/PollingConfigBuilder.kt @@ -0,0 +1,45 @@ +package `in`.androidplay.pollingengine.polling.builder + +import `in`.androidplay.pollingengine.models.Error +import `in`.androidplay.pollingengine.models.PollingResult +import `in`.androidplay.pollingengine.polling.BackoffPolicy +import `in`.androidplay.pollingengine.polling.PollingConfig +import `in`.androidplay.pollingengine.polling.PollingOutcome +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@DslMarker +public annotation class PollingBuilderMarker + +@PollingBuilderMarker +public class PollingConfigBuilder { + + public var fetch: (suspend () -> PollingResult)? = null + public var isTerminalSuccess: ((T) -> Boolean)? = null + public var shouldRetryOnError: (Error?) -> Boolean = { true } + public var backoff: BackoffPolicy = BackoffPolicy() + public var dispatcher: CoroutineDispatcher = Dispatchers.Default + public var onAttempt: (attempt: Int, delayMs: Long?) -> Unit = { _, _ -> } + public var onResult: (attempt: Int, result: PollingResult) -> Unit = { _, _ -> } + public var onComplete: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit = + { _, _, _ -> } + public var throwableMapper: (Throwable) -> Error = { t -> + val msg = t.message ?: (t::class.simpleName ?: "Throwable") + Error(-1, msg) + } + + public fun build(): PollingConfig { + return PollingConfig( + fetch = fetch ?: throw IllegalStateException("fetch must be set"), + isTerminalSuccess = isTerminalSuccess + ?: throw IllegalStateException("isTerminalSuccess must be set"), + shouldRetryOnError = shouldRetryOnError, + backoff = backoff, + dispatcher = dispatcher, + onAttempt = onAttempt, + onResult = onResult, + onComplete = onComplete, + throwableMapper = throwableMapper + ) + } +} diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/BackoffPolicyTest.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/BackoffPolicyTest.kt index 57b9b78..90322de 100644 --- a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/BackoffPolicyTest.kt +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/BackoffPolicyTest.kt @@ -8,15 +8,36 @@ import kotlin.test.assertTrue class BackoffPolicyTest { @Test fun invalidParameters_throwIllegalArgumentException() { - assertFailsWith { BackoffPolicy(initialDelayMs = -1) } - assertFailsWith { BackoffPolicy(maxDelayMs = 0) } - assertFailsWith { BackoffPolicy(maxAttempts = 0) } - assertFailsWith { BackoffPolicy(overallTimeoutMs = 0) } - assertFailsWith { BackoffPolicy(multiplier = 0.9) } - assertFailsWith { BackoffPolicy(jitterRatio = -0.1) } - assertFailsWith { BackoffPolicy(jitterRatio = 1.1) } - assertFailsWith { BackoffPolicy(perAttemptTimeoutMs = 0) } - assertFailsWith { BackoffPolicy(initialDelayMs = 2000, maxDelayMs = 1000) } + assertFailsWith { + BackoffPolicy(initialDelayMs = -1) + } + assertFailsWith { + BackoffPolicy(maxDelayMs = 0) + } + assertFailsWith { + BackoffPolicy(maxAttempts = 0) + } + assertFailsWith { + BackoffPolicy(overallTimeoutMs = 0) + } + assertFailsWith { + BackoffPolicy(multiplier = 0.9) + } + assertFailsWith { + BackoffPolicy(jitterRatio = -0.1) + } + assertFailsWith { + BackoffPolicy(jitterRatio = 1.1) + } + assertFailsWith { + BackoffPolicy(perAttemptTimeoutMs = 0) + } + assertFailsWith { + BackoffPolicy( + initialDelayMs = 2000, + maxDelayMs = 1000, + ) + } } @Test @@ -28,7 +49,7 @@ class BackoffPolicyTest { maxDelayMs = 10_000, multiplier = 2.0, jitterRatio = jitter, - random = Random(0) + random = Random(0), ) val minBound = (base * (1 - jitter)).toLong() val maxBound = (base * (1 + jitter)).toLong() diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt index 0b245b3..971db0b 100644 --- a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCancellationTest.kt @@ -23,7 +23,7 @@ class PollingEngineCancellationTest { maxAttempts = 1, // single attempt overallTimeoutMs = 5_000, perAttemptTimeoutMs = null, - ) + ), ) val outcome = Polling.run(config) diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt index 6cb97c1..afeca75 100644 --- a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt @@ -28,13 +28,12 @@ class PollingEngineCoreTests { jitterRatio = 0.0, maxAttempts = 10, overallTimeoutMs = 5_000, - random = Random(0) + random = Random(0), ) ) val outcome = Polling.run(config) assertTrue(outcome is PollingOutcome.Success) - outcome as PollingOutcome.Success assertEquals(100, outcome.value) assertEquals(3, outcome.attempts) } @@ -51,7 +50,7 @@ class PollingEngineCoreTests { multiplier = 1.0, jitterRatio = 0.0, maxAttempts = 5, - overallTimeoutMs = 5_000 + overallTimeoutMs = 5_000, ) ) @@ -78,12 +77,12 @@ class PollingEngineCoreTests { multiplier = 1.0, jitterRatio = 0.0, maxAttempts = 3, - overallTimeoutMs = 5_000 + overallTimeoutMs = 5_000, ) ) val outcome = Polling.run(config) assertTrue(outcome is PollingOutcome.Exhausted) - assertEquals(3, (outcome as PollingOutcome.Exhausted).attempts) + assertEquals(3, outcome.attempts) } @Test @@ -138,6 +137,6 @@ class PollingEngineCoreTests { ) val outcome = Polling.compose(cfg1, cfg2) assertTrue(outcome is PollingOutcome.Exhausted) - assertEquals(1, (outcome as PollingOutcome.Exhausted).attempts) + assertEquals(1, outcome.attempts) } } From 0d9e0ea35a4c81edd8b1262226e8cd9f80b8262f Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Mon, 8 Sep 2025 18:29:33 +0530 Subject: [PATCH 3/4] fix: Ensure accurate countdown timer during polling pause/resume This commit addresses an issue where the overall countdown timer for polling operations did not accurately reflect the paused state. **Key Changes:** * **`PollingEngine.kt`:** * The internal delay mechanism within the polling loop has been refined. Instead of a single `delay()` call, it now uses a `while` loop with smaller delay steps (`delayStep = 100L`). * During each step, it checks if the poll is paused. If paused, it suspends execution using `control.state.map { it == State.Running }.first { it }` until resumed. * This ensures that the overall timeout countdown effectively freezes when a poll is paused and resumes correctly when the poll is resumed. * **`PollingViewModel.kt` (Sample App):** * The logic for identifying the `pollingSession` ID after starting a new poll has been made more robust. * It now captures the set of active polling IDs before and after launching the new poll. The difference between these sets is used to identify the newly created session ID. * Includes a short retry mechanism (`repeat(10) { delay(50) }`) to allow time for the new session to be registered in `Polling.listActiveIds()`. * A fallback is added: if a unique ID cannot be determined via the diff, but there's exactly one active poll, that ID is used. * Error logging is added if the session ID cannot be reliably determined, as this would affect pause/resume functionality in the sample app. * The `startCountdown()` is now called after the session ID is likely established to ensure the countdown is associated with the correct polling operation. * Added `try-catch` around the polling start logic to handle potential errors during initialization and update UI state accordingly. * **`PollingEngineCoreTests.kt`:** * Removed an unused `outcome` variable read in `testMaxAttemptsReachedWithRetryFalse`. **Functionality Improvements:** * The overall timeout countdown timer in the `PollingEngine` now accurately pauses when a polling operation is paused and resumes when it's resumed. * The sample application's `PollingViewModel` is more reliable in tracking the active polling session, improving the behavior of its pause/resume feature. --- .../pollingengine/PollingViewModel.kt | 128 ++++++++++++------ .../pollingengine/polling/PollingEngine.kt | 17 ++- .../polling/PollingEngineCoreTests.kt | 1 - 3 files changed, 97 insertions(+), 49 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt index aeb6121..7142247 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt @@ -141,51 +141,93 @@ class PollingViewModel { } var attemptCounter = 0 - pollingJob = Polling.startPolling { - this.fetch = { - attemptCounter++ - if (attemptCounter < 8) PollingResult.Waiting else PollingResult.Success("Ready at attempt #$attemptCounter") - } - this.isTerminalSuccess = { it.isNotEmpty() } - this.backoff = backoff - this.shouldRetryOnError = retryStrategies[_uiState.value.retryStrategyIndex].second - this.onAttempt = { attempt, delayMs -> - val baseDelay = - (backoff.initialDelayMs * backoff.multiplier.pow((attempt - 1).toDouble())).toLong() - .coerceAtMost(backoff.maxDelayMs) - val baseSecs = ((baseDelay) / 100L).toFloat() / 10f - val baseSecsStr = ((round(baseSecs * 10f)) / 10f).toString() - val actualDelay = delayMs ?: 0L - val actualSecs = (actualDelay / 100L).toFloat() / 10f - val actualSecsStr = ((round(actualSecs * 10f)) / 10f).toString() - addLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") - } - this.onResult = { attempt, result -> - addLog("[info] Result at #$attempt: ${describeResult(result)}") - } - this.onComplete = { attempts, durationMs, outcome -> - val secs = (durationMs / 100L).toFloat() / 10f - val secsStr = ((round(secs * 10f)) / 10f).toString() - addLog( - "[done] Completed after $attempts attempts in ${secsStr}s: ${ - describeOutcome( - outcome + + // Capture active IDs before starting, to identify the new session after launch + viewModelScope.launch { + try { + val beforeIds = Polling.listActiveIds().toSet() + + // Start and collect the polling flow + pollingJob = Polling.startPolling { + this.fetch = { + attemptCounter++ + if (attemptCounter < 8) PollingResult.Waiting else PollingResult.Success("Ready at attempt #$attemptCounter") + } + this.isTerminalSuccess = { it.isNotEmpty() } + this.backoff = backoff + this.shouldRetryOnError = + retryStrategies[_uiState.value.retryStrategyIndex].second + this.onAttempt = { attempt, delayMs -> + val baseDelay = + (backoff.initialDelayMs * backoff.multiplier.pow((attempt - 1).toDouble())).toLong() + .coerceAtMost(backoff.maxDelayMs) + val baseSecs = ((baseDelay) / 100L).toFloat() / 10f + val baseSecsStr = ((round(baseSecs * 10f)) / 10f).toString() + val actualDelay = delayMs ?: 0L + val actualSecs = (actualDelay / 100L).toFloat() / 10f + val actualSecsStr = ((round(actualSecs * 10f)) / 10f).toString() + addLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") + } + this.onResult = { attempt, result -> + addLog("[info] Result at #$attempt: ${describeResult(result)}") + } + this.onComplete = { attempts, durationMs, outcome -> + val secs = (durationMs / 100L).toFloat() / 10f + val secsStr = ((round(secs * 10f)) / 10f).toString() + addLog( + "[done] Completed after $attempts attempts in ${secsStr}s: ${ + describeOutcome( + outcome + ) + }" ) - }" - ) - } - }.onEach { outcome -> - addLog("[done] Final Outcome: ${describeOutcome(outcome)}") - _uiState.update { - it.copy( - isRunning = false, - isPaused = false, - remainingMs = 0 - ) - } - }.launchIn(viewModelScope) + } + }.onEach { outcome -> + addLog("[done] Final Outcome: ${describeOutcome(outcome)}") + _uiState.update { + it.copy( + isRunning = false, + isPaused = false, + remainingMs = 0 + ) + } + // Clear session on terminal outcome + pollingSession = null + }.launchIn(this) + + // Try to resolve the newly created session ID with a short retry window + var resolved: String? = null + repeat(10) { // ~ up to 500ms (10 * 50ms) + val afterIds = Polling.listActiveIds().toSet() + val diff = afterIds - beforeIds + if (diff.isNotEmpty()) { + resolved = diff.first() + return@repeat + } + kotlinx.coroutines.delay(50) + } - startCountdown() + if (resolved != null) { + pollingSession = PollingSession(resolved) + addLog("[info] Session started (id=${resolved})") + } else { + // Fallback: if exactly one active, take it + val active = Polling.listActiveIds() + if (active.size == 1) { + pollingSession = PollingSession(active.first()) + addLog("[info] Session started (id=${active.first()})") + } else { + addLog("[error] Could not determine session id; pause/resume may not work.") + } + } + + // Start countdown after session likely established + startCountdown() + } catch (t: Throwable) { + addLog("[error] Failed to start polling: ${t.message}") + _uiState.update { it.copy(isRunning = false, isPaused = false) } + } + } } private fun pauseOrResumePolling() { diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt index 2adf058..c1a9880 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt @@ -257,12 +257,19 @@ internal object PollingEngine { config.onAttempt(nextAttemptIndex, sleepMs) } - // Suspend while paused - if (control.state.value == State.Paused) { - control.state.map { it == State.Running }.first { it } + // Wait for the required delay, pausing countdown if paused + var remainingDelay = minOf(sleepMs, remainingBeforeSleep) + val delayStep = 100L // ms granularity for checking pause/resume + while (remainingDelay > 0) { + if (control.state.value == State.Paused) { + // Wait until resumed + control.state.map { it == State.Running }.first { it } + } else { + val step = minOf(delayStep, remainingDelay) + delay(step) + remainingDelay -= step + } } - - delay(minOf(sleepMs, remainingBeforeSleep)) } val totalMs = startMark.elapsedNow().inWholeMilliseconds diff --git a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt index afeca75..fc6a6c0 100644 --- a/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt @@ -56,7 +56,6 @@ class PollingEngineCoreTests { val outcome = Polling.run(config) assertTrue(outcome is PollingOutcome.Exhausted) - outcome assertTrue(outcome.last is Failure) assertEquals(1, outcome.attempts) } From 7e36599b106882c29d6cf9ee518beb6fb1cc272a Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Mon, 8 Sep 2025 18:35:40 +0530 Subject: [PATCH 4/4] fix(ci): Configure CodeQL for specific Kotlin and Swift builds This commit updates the CodeQL workflow to improve analysis for Kotlin Multiplatform and Swift: * **Java/Kotlin Analysis:** * Adds Java 17 (Temurin) setup using `actions/setup-java@v4` with Gradle caching. * Replaces `codeql-action/autobuild` with a specific Gradle command (`./gradlew :pollingengine:compileKotlinMetadata`) to build only the common Kotlin metadata. This avoids unnecessary Android/iOS toolchain setup and potential conflicts during CodeQL's Java analysis. * **Swift Analysis:** * Replaces `codeql-action/autobuild` with an explicit `xcodebuild` command to build the iOS app for the simulator. This ensures the Swift code is compiled in a way that CodeQL can effectively analyze, with `CODE_SIGNING_ALLOWED=NO` to avoid signing issues in CI. These changes provide more targeted and reliable builds for CodeQL analysis, focusing on the relevant source code for each language. --- .github/workflows/codeql.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5c4c0af..01aaa29 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,10 +18,17 @@ jobs: security-events: write steps: - uses: actions/checkout@v5 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' - uses: github/codeql-action/init@v3 with: languages: 'java-kotlin' - - uses: github/codeql-action/autobuild@v3 + # Custom build steps for Kotlin Multiplatform (avoid Android/iOS toolchains) + - name: Build Kotlin common metadata + run: ./gradlew :pollingengine:compileKotlinMetadata --no-daemon --stacktrace - uses: github/codeql-action/analyze@v3 analyze-swift: @@ -36,5 +43,7 @@ jobs: - uses: github/codeql-action/init@v3 with: languages: 'swift' - - uses: github/codeql-action/autobuild@v3 + # Custom build steps for Swift (explicit xcodebuild) + - name: Build iOS app for simulator (no code signing) + run: xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -configuration Release CODE_SIGNING_ALLOWED=NO build - uses: github/codeql-action/analyze@v3