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 diff --git a/README.md b/README.md index ca63118..a1e1dc3 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-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) [![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. @@ -47,29 +51,29 @@ 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 Coordinates on Maven Central: -- groupId: io.github.bosankus +- groupId: in.androidplay - artifactId: pollingengine -- version: 0.1.0 +- version: 0.1.1 Gradle Kotlin DSL (Android/shared): ```kotlin repositories { mavenCentral() } -dependencies { implementation("io.github.bosankus:pollingengine:0.1.0") } +dependencies { implementation("in.androidplay:pollingengine:0.1.1") } ``` Gradle Groovy DSL: ```groovy repositories { mavenCentral() } -dependencies { implementation "io.github.bosankus:pollingengine:0.1.0" } +dependencies { implementation "in.androidplay:pollingengine:0.1.1" } ``` Maven: @@ -77,9 +81,9 @@ Maven: ```xml - io.github.bosankus + in.androidplay pollingengine - 0.1.0 + 0.1.1 ``` @@ -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,59 @@ 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 + +Start polling by collecting the returned Flow, and control active sessions by ID: + +```kotlin +// 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) + + // Update backoff at runtime + Polling.updateBackoff(id, BackoffPolicies.quick20s) + + // Cancel + Polling.cancel(id) +} + +// Or cancel all +Polling.cancelAll() + +// Stop collecting if needed +job.cancel() +``` + +## 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 + +- 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/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..62254d2 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -2,7 +2,6 @@ package `in`.androidplay.pollingengine 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 @@ -17,7 +16,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DividerDefaults @@ -27,71 +27,21 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.PollingEngine -import `in`.androidplay.pollingengine.polling.PollingOutcome -import `in`.androidplay.pollingengine.polling.pollingConfig -import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview -import kotlin.math.pow +import kotlin.math.round -// ------- Models and helpers (moved above App to avoid any potential local-declaration parsing issues) ------- - -@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(androidx.compose.foundation.shape.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 -> - androidx.compose.animation.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 { @@ -124,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=${ - ((kotlin.math.round( - secs * 10f - )) / 10f) - })" - } - - is PollingOutcome.Exhausted -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.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)})" - } - - is PollingOutcome.Cancelled -> { - val secs = (outcome.elapsedMs / 100L).toFloat() / 10f - "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.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 @@ -259,27 +159,63 @@ private fun GlowingButton( } } -private fun buildTechTypography( - base: androidx.compose.material3.Typography, - family: FontFamily -): androidx.compose.material3.Typography { - return androidx.compose.material3.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 ------- @@ -287,308 +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 = androidx.compose.material3.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 = androidx.compose.material3.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) } + val viewModel = remember { PollingViewModel() } + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() - // 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 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)) - - // Start button + countdown - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - GlowingButton( - enabled = true, - text = when { - !isRunning -> "Start Polling" - isPaused -> "Resume" - else -> "Pause" - }, - onClick = { - fun appendLog(msg: String) { - scope.launch { logs.add(msg) } - } - 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 - } + 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)) + + 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) } + ) - val backoff = try { - BackoffPolicy( - initialDelayMs = initialDelay, - maxDelayMs = maxDelay, - multiplier = multiplier, - jitterRatio = jitter, - maxAttempts = maxAttempts, - overallTimeoutMs = overallTimeout, - perAttemptTimeoutMs = perAttemptTimeout, + 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 ) - } 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 - ) - }" + } + } + 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 ) } - 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 - ) - }" + 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 ) } - } - // Start countdown ticker respecting pause - scope.launch { - while (isRunning && remainingMs > 0) { - kotlinx.coroutines.delay(100) - if (!isPaused) remainingMs = - (remainingMs - 100).coerceAtLeast(0) + 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 + ) - // Start polling - handle = PollingEngine.startPolling(config) { outcome -> - appendLog("[done] Final Outcome: ${describeOutcome(outcome)}") - isRunning = false - isPaused = false - remainingMs = 0 - handle = null - } - } 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 - } + // Live update button + GlowingButton( + enabled = uiState.isRunning, + text = "Apply Backoff at Runtime", + onClick = { viewModel.dispatch(PollingIntent.ApplyBackoffAtRuntime) } + ) } } - } - ) - Spacer(Modifier.weight(1f)) - val secs = (remainingMs / 100L).toFloat() / 10f - val secsStr = ((kotlin.math.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 = "Stop", - onClick = { - handle?.let { h -> - scope.launch { PollingEngine.cancel(h) } - } - isRunning = false - isPaused = false - handle = null - remainingMs = 0 - } - ) - } - - Spacer(Modifier.height(16.dp)) - - // 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) } - } - - 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 { - PollingEngine.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)) - - // Terminal Log view - TerminalLog(modifier = Modifier.weight(1f), logs = logs) + item { + Spacer(Modifier.height(24.dp)) + } + items(items = uiState.logs, key = { it.id }) { line -> + LogEntryCard(line = line.text) + } } } } -} \ No newline at end of file +} 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/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..7142247 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/PollingViewModel.kt @@ -0,0 +1,340 @@ +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 + + // 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 + ) + } + // 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) + } + + 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() { + 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/ci-setup.md b/docs/ci-setup.md deleted file mode 100644 index 4f2717d..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-05 - -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 96d3301..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" } @@ -26,7 +28,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..f9af9d9 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" @@ -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 73d7f09..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,58 +3,60 @@ 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 { +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) } -// --- 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 + ErrorCodes.NETWORK_ERROR, + ErrorCodes.SERVER_ERROR_CODE, + ErrorCodes.TIMEOUT_ERROR_CODE, + ErrorCodes.UNKNOWN_ERROR_CODE, + -> true else -> false } } @@ -68,8 +70,26 @@ public object DefaultRetryPredicates { // --- 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 new file mode 100644 index 0000000..85ca29c --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/Polling.kt @@ -0,0 +1,41 @@ +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. + * + * 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 + ): 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) + + 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..d6b64e7 --- /dev/null +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingAPI.kt @@ -0,0 +1,53 @@ +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. + */ +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 + ): 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 + + /** 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..c1a9880 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 @@ -28,11 +32,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 +48,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 +57,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,17 +98,17 @@ public object PollingEngine { supervisor.cancel(CancellationException("PollingEngine shutdown")) } - public fun startPolling( - config: PollingConfig, - onComplete: (PollingOutcome) -> Unit, - ): Handle { + fun startPolling( + 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,20 +116,20 @@ public 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) } /** * 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 +148,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( @@ -158,10 +162,6 @@ public 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++ @@ -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,11 +254,22 @@ 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) } - delay(minOf(sleepMs, remainingBeforeSleep)) + // 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 + } + } } val totalMs = startMark.elapsedNow().inWholeMilliseconds @@ -275,7 +279,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/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 193b0b8..971db0b 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 @@ -23,10 +23,10 @@ class PollingEngineCancellationTest { maxAttempts = 1, // single attempt overallTimeoutMs = 5_000, perAttemptTimeoutMs = null, - ) + ), ) - 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..fc6a6c0 --- /dev/null +++ b/pollingengine/src/commonTest/kotlin/in/androidplay/pollingengine/polling/PollingEngineCoreTests.kt @@ -0,0 +1,141 @@ +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) + 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) + 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.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.attempts) + } +}